~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
382 lines · 11.2 KB Raw History
import {
  LockIcon,
  MailIcon,
  ServerIcon,
  TriangleAlertIcon,
  UploadIcon,
} from 'lucide-react';
import { useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Badge } from '#/components/atoms/badge.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import {
  nsidDomain,
  pickLangString,
  pluralNoun,
  recordNoun,
  scopeTokens,
} from '#/lib/format.js';
import { cn } from '#/lib/utils.js';

const ACTIONS = ['create', 'update', 'delete'];

/** Join words with commas and "and": ["a","b","c"] -> "a, b, and c". */
function listWords(words) {
  if (words.length <= 1) return words[0] || '';
  if (words.length === 2) return `${words[0]} and ${words[1]}`;
  return `${words.slice(0, -1).join(', ')}, and ${words.at(-1)}`;
}

/** The plural nouns for a set of collections, deduped, reading order kept. */
function nounList(entries) {
  const nouns = [];
  for (const entry of entries) {
    const noun = pluralNoun(recordNoun(entry.collection));
    if (!nouns.includes(noun)) nouns.push(noun);
  }
  return listWords(nouns);
}

/** An icon chip sized to sit where an app tile would, fronting a card. */
function ScopeIcon({ children }) {
  return (
    <div className="grid size-7 shrink-0 place-items-center rounded-lg bg-accent text-key [&_svg]:size-[15px]">
      {children}
    </div>
  );
}

/** A card in the consent list: leading tile, summary prose, optional detail. */
function ScopeCard({ tile, title, subtitle, children, detail, detailLabel }) {
  const [open, setOpen] = useState(false);
  // A tile with no title fronts the prose itself: one sentence, one icon.
  if (tile && !title && !detail) {
    return (
      <div className="flex items-center gap-2.5 rounded-2xl border border-border bg-muted p-4">
        {tile}
        <div className="min-w-0 text-[13px] text-muted-foreground leading-relaxed">
          {children}
        </div>
      </div>
    );
  }
  return (
    <div className="rounded-2xl border border-border bg-muted p-4">
      {(tile || title) && (
        <div className="mb-2 flex items-center gap-2.5">
          {tile}
          <div className="min-w-0">
            {title && (
              <div className="truncate font-bold text-[13px] text-foreground">
                {title}
              </div>
            )}
            {subtitle && (
              <div className="truncate text-[11.5px] text-faint">
                {subtitle}
              </div>
            )}
          </div>
        </div>
      )}
      <div className="space-y-1 text-[13px] text-muted-foreground leading-relaxed">
        {children}
      </div>
      {detail && (
        <>
          <TextLink onClick={() => setOpen(!open)} className="mt-2">
            {open ? 'Hide details' : detailLabel}
          </TextLink>
          {open && <div className="mt-3 flex flex-col gap-3">{detail}</div>}
        </>
      )}
    </div>
  );
}

/**
 * One details section: a faint action heading over a clean list of NSIDs.
 * No per-row chips; the heading says once what applies to every name below.
 */
function DetailGroup({ label, items }) {
  return (
    <div>
      <div className="mb-1 text-[10.5px] text-faint uppercase tracking-wider">
        {label}
      </div>
      <div className="flex flex-col gap-1">
        {items.map((item) => (
          <span
            key={item}
            className="break-all font-mono text-[12.5px] text-foreground"
          >
            {item}
          </span>
        ))}
      </div>
    </div>
  );
}

/** Repo detail sections, one per shared action signature. */
function repoDetailGroups(bySignature) {
  return [...bySignature.values()].map(({ granted, entries }) => (
    <DetailGroup
      key={granted.join('+')}
      label={
        granted.length === ACTIONS.length ? 'full control' : granted.join(', ')
      }
      items={entries.map((entry) => entry.collection)}
    />
  ));
}

/** Rpc detail sections, one per audience. */
function rpcDetailGroups(grants) {
  return grants.map(({ aud, lxms }) => (
    <DetailGroup
      key={aud}
      label={`calls to ${audLabel(aud)}`}
      items={lxms === '*' ? ['* (any method)'] : lxms}
    />
  ));
}

/** Collections bucketed by which actions they share. */
function groupBySignature(entries) {
  const bySignature = new Map();
  for (const entry of entries) {
    const granted = ACTIONS.filter((action) => entry[action]);
    const key = granted.join('+');
    const group = bySignature.get(key) || { granted, entries: [] };
    group.entries.push(entry);
    bySignature.set(key, group);
  }
  return bySignature;
}

/** Repo entries for one app, summarized by what they let the app do. */
function RepoGroupCard({ domain, entries }) {
  // Sentences group collections that share the same granted actions, so ten
  // full-control record types collapse into one line instead of ten rows.
  const bySignature = groupBySignature(entries);

  const typeCount = entries.length;
  return (
    <ScopeCard
      tile={<AppTile domain={domain} label={domain} size={28} />}
      title={domain}
      detail={repoDetailGroups(bySignature)}
      detailLabel={`${typeCount} record ${typeCount === 1 ? 'type' : 'types'}. Details`}
    >
      {[...bySignature.values()].map(({ granted, entries: grouped }) => (
        <p key={granted.join('+')}>
          {granted.length === ACTIONS.length
            ? `Full control of ${nounList(grouped)}.`
            : `Can ${listWords(granted)} ${nounList(grouped)}.`}
        </p>
      ))}
    </ScopeCard>
  );
}

/** A human name for an rpc audience DID. */
function audLabel(aud) {
  if (aud === '*') return 'any service';
  const web = /^did:web:([^#]+)/.exec(aud);
  if (web) return web[1];
  return aud.replace(/#.*$/, '');
}

/** rpc: grants, one line per audience, exact methods behind the details. */
function RpcCard({ grants }) {
  return (
    <ScopeCard
      tile={
        <ScopeIcon>
          <ServerIcon />
        </ScopeIcon>
      }
      title="Services it can call"
      detail={rpcDetailGroups(grants)}
      detailLabel="Details"
    >
      {grants.map(({ aud, lxms }) => (
        <p key={aud}>
          {lxms === '*'
            ? `Can call any method on ${audLabel(aud)}.`
            : `Can call ${lxms.length} ${lxms.length === 1 ? 'method' : 'methods'} on ${audLabel(aud)}.`}
        </p>
      ))}
    </ScopeCard>
  );
}

/** A permission set the request includes, by its author's own description. */
function IncludeCard({ include }) {
  const title =
    pickLangString(include.title, include.titleLangs) || include.nsid;
  const detailText = pickLangString(include.detail, include.detailLangs);
  const sections = [
    ...repoDetailGroups(groupBySignature(include.repo)),
    ...rpcDetailGroups(include.rpc),
  ];
  return (
    <ScopeCard
      tile={
        <AppTile
          domain={nsidDomain(include.nsid)}
          label={include.nsid}
          size={28}
        />
      }
      title={title}
      subtitle={title === include.nsid ? null : include.nsid}
      detail={sections.length > 0 ? sections : null}
      detailLabel="Details"
    >
      {detailText && <p>{detailText}</p>}
    </ScopeCard>
  );
}

/** What an OAuth grant covers, requested on consent or held by a session. */
export function ScopeSummary({ perms, className }) {
  const [showRaw, setShowRaw] = useState(false);
  const repo = perms.repo || [];
  const rpc = perms.rpc || [];
  const includes = perms.includes || [];
  const blobs = perms.blobs || [];
  const spaces = perms.spaces || [];
  const nothing =
    !perms.hasTransitionGeneric &&
    !perms.hasEmail &&
    !repo.length &&
    !rpc.length &&
    !includes.length &&
    !blobs.length &&
    !spaces.length;

  if (nothing) {
    return (
      <div
        className={cn(
          'mt-4 rounded-2xl border border-border bg-muted px-4 py-3 text-[13px] text-muted-foreground leading-relaxed',
          className,
        )}
      >
        It only confirms who you are. No access to your data.
      </div>
    );
  }

  // Group repo grants under the app whose namespace they belong to, keeping
  // wildcard grants apart: "any record type" is not an app's records.
  const groups = new Map();
  const wildcard = [];
  for (const entry of repo) {
    if (entry.collection.includes('*')) {
      wildcard.push(entry);
      continue;
    }
    const domain = nsidDomain(entry.collection);
    const group = groups.get(domain) || [];
    group.push(entry);
    groups.set(domain, group);
  }

  return (
    <div className={cn('mt-4 space-y-3', className)}>
      {perms.hasTransitionGeneric && (
        <div className="flex items-center gap-2.5 rounded-2xl border border-number/30 bg-number/10 px-4 py-3 text-[13px] text-foreground leading-relaxed">
          <TriangleAlertIcon className="size-[15px] shrink-0 text-number" />
          <span>
            Full access to everything in your account. This app uses broad
            legacy access.
          </span>
        </div>
      )}
      {includes.map((include) => (
        <IncludeCard key={`${include.nsid}:${include.aud}`} include={include} />
      ))}
      {[...groups.entries()].map(([domain, entries]) => (
        <RepoGroupCard key={domain} domain={domain} entries={entries} />
      ))}
      {wildcard.length > 0 && (
        <ScopeCard title="All records">
          {wildcard.map((entry) => {
            const granted = ACTIONS.filter((action) => entry[action]);
            return (
              <p key={entry.collection}>
                {granted.length === ACTIONS.length
                  ? 'Full control of records of any type.'
                  : `Can ${listWords(granted)} records of any type.`}
              </p>
            );
          })}
        </ScopeCard>
      )}
      {rpc.length > 0 && <RpcCard grants={rpc} />}
      {perms.hasEmail && (
        <ScopeCard
          tile={
            <ScopeIcon>
              <MailIcon />
            </ScopeIcon>
          }
        >
          <p>Can see your account email address.</p>
        </ScopeCard>
      )}
      {blobs.length > 0 && (
        <ScopeCard
          tile={
            <ScopeIcon>
              <UploadIcon />
            </ScopeIcon>
          }
        >
          <p>
            Can upload files
            {blobs.includes('*/*') ? ' of any type.' : `: ${blobs.join(', ')}.`}
          </p>
        </ScopeCard>
      )}
      {spaces.length > 0 && (
        <ScopeCard
          tile={
            <ScopeIcon>
              <LockIcon />
            </ScopeIcon>
          }
          title="Spaces"
        >
          {spaces.map((space, index) => (
            <p
              key={space.skey || index}
              className="break-all font-mono text-[12.5px] text-foreground"
            >
              {space.spaceType}
            </p>
          ))}
        </ScopeCard>
      )}
      {perms.scope && (
        <div className="px-1">
          <TextLink onClick={() => setShowRaw(!showRaw)}>
            {showRaw ? 'Hide technical details' : 'Technical details'}
          </TextLink>
          {showRaw && (
            <div className="mt-2 flex flex-wrap gap-1">
              {scopeTokens(perms.scope).map((token) => (
                <Badge key={token} variant="scope" className="break-all">
                  {token}
                </Badge>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}