types.ts16.7 KBView on GitHub
import type { GrantRole, Principal } from '@zero/server/access';
import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@zero/server/trpc';

/**
 * The share panel's vocabulary.
 *
 * Every shape here is DERIVED from the router rather than declared beside it. That is
 * not tidiness: the panel's predecessor declared `{ id, name }[]` for a procedure that
 * returned `{ aops: [...] }`, and the cast overrode the correct inferred type until
 * `.map is not a function` took the home rail down through the error boundary. A
 * derived type cannot be wrong about the server; a declared one is wrong the day the
 * route changes and says nothing about it.
 *
 * Design: apps/server/docs/sharing.md §3.3.
 */
type RouterOutputs = inferRouterOutputs<AppRouter>;

export type DocumentAudience = RouterOutputs['files']['audience'];
export type AudienceEntry = DocumentAudience['entries'][number];
export type VisibilityPreview = RouterOutputs['files']['visibilityPreview'];
export type OrgMember = RouterOutputs['organisation']['listMembers']['members'][number];

export type { GrantRole, Principal };

/**
 * The four roles a share can hand out, weakest first.
 *
 * `owner` is deliberately not on it. Ownership is `documents.user_id` — a fact about
 * the row, not a grant — so a menu that offered it would be offering to write
 * something `grantAccess` does not accept. `files.grantAccess`'s own zod enum is this
 * same four, which is what keeps the menu and the mutation from disagreeing.
 */
export const ROLE_LADDER = ['viewer', 'commenter', 'editor', 'manager'] as const;

export type ShareableRole = (typeof ROLE_LADDER)[number];

/**
 * `manager` is called **Full access**, not "Can manage".
 *
 * Set against "Can edit", "Can manage" reads as a shade of the same thing — both are
 * verbs about the document, and nothing in either word says which one may hand it to
 * somebody else. That is the entire difference: `manager` is the standing that may
 * RE-SHARE (and delete). "Full access" says the size of the thing rather than naming
 * one more verb, so the ladder reads as three widths rather than four synonyms — and
 * it is the word Notion uses for the same rung, which is where readers have met it.
 */
export const ROLE_LABEL: Record<GrantRole, string> = {
  viewer: 'Can view',
  commenter: 'Can comment',
  editor: 'Can edit',
  manager: 'Full access',
  owner: 'Owner',
};

/** `manager` and `owner` are the two standings that may re-share. */
export function isManagerRole(role: GrantRole): boolean {
  return role === 'manager' || role === 'owner';
}

/**
 * A row's identity — the same key the server's audience fold uses, minus the type.
 *
 * The KIND is part of it for the reason the server states in `audience.ts`: an agent
 * whose id happened to match a user's would otherwise fold into that person's row and
 * one of the two standings would vanish. Three kinds, because there are three kinds of
 * subject in an audience — a person, the organisation, and an agent's holders.
 */
export function entryKey(entry: Pick<AudienceEntry, 'userId' | 'email' | 'via'>): string {
  const kind = entry.via === 'org' ? 'org' : entry.via === 'agent' ? 'agent' : 'user';
  return `${kind}:${entry.userId ?? entry.email ?? ''}`;
}

/**
 * The principal a row is ABOUT — read off its `via`, never guessed from its shape.
 *
 * Three call sites used to derive this inline as `entry.userId ? user : entry.email ?
 * email : null`, which is right for a person and silently wrong for anything else: the
 * ORG_AUDIENCE line and an `agent` row both carry their subject's id in `userId`, so
 * that ternary would have revoked a USER grant addressed with an organisation's or an
 * agent's id — a write that succeeds, changes nothing, and leaves the row exactly where
 * it was. `via` is the field that already knows which kind of subject this is.
 *
 * Null for a row there is no principal for at all: an unredeemed `email` grant folded
 * into a line with neither an id nor an address cannot be addressed by a mutation.
 */
export function principalOf(
  entry: Pick<AudienceEntry, 'userId' | 'email' | 'via'>,
): Principal | null {
  if (entry.via === 'org') return entry.userId ? { type: 'org', id: entry.userId } : null;
  if (entry.via === 'agent') return entry.userId ? { type: 'agent', id: entry.userId } : null;
  if (entry.userId) return { type: 'user', id: entry.userId };
  if (entry.email) return { type: 'email', id: entry.email };
  return null;
}

/**
 * The one segment an agent's id occupies in a path — `…/agent-{id}/…`.
 *
 * A second literal anywhere would be a path this file cannot recognise as an agent's,
 * which fails as "the panel offered the organisation for a document that is inside an
 * agent" — the exact confusion Phase 5 exists to remove.
 */
const AGENT_SEGMENT_PREFIX = 'agent-';

/**
 * Which agent's namespace is this document inside — the client's copy of the server's
 * `agentIdFromPath` (`apps/server/src/services/documents/convention-paths.ts`).
 *
 * ── WHY IT IS COPIED AND NOT IMPORTED ───────────────────────────────────────
 *
 * Everything else in this file is DERIVED from the router rather than declared, and
 * that works because those are types: `import type` erases, so nothing of the server
 * reaches the browser. This is a function, and a value import of
 * `services/documents/convention-paths` would pull `node:crypto` and the whole
 * documents service — its db handle, its env — into the mail bundle. It is not on
 * `@zero/server`'s exports map for that reason; the modules that ARE (`./table`,
 * `./access`) are dependency-free leaves, and this one is not.
 *
 * What keeps the two copies honest is the shape of what they read rather than a shared
 * import. The id occupies exactly ONE path segment and is written in exactly one place
 * (`agentNamespacePath`), so all three namespaces — `user/agent-{id}`,
 * `organisation/agent-{id}`, `conversation/{cid}/agent-{id}` — and everything nested
 * under them answer with the same segment scan. Deliberately scope-blind, for the same
 * reason the server's is: the same agent is `user/agent-x` for one person and
 * `organisation/agent-x` once published, and a panel whose subject depended on where
 * somebody published from would offer a different audience for the same agent.
 *
 * A segment test, never `path.includes('agent-')`: a folder somebody named
 * `my-agent-x-plan` is not inside an agent, and the server made the same correction
 * for the same reason when it rewrote the filter arm (design doc Phase 4).
 */
export function agentIdFromPath(path: string): string | null {
  for (const segment of path.split('/')) {
    if (segment.startsWith(AGENT_SEGMENT_PREFIX) && segment.length > AGENT_SEGMENT_PREFIX.length) {
      return segment.slice(AGENT_SEGMENT_PREFIX.length);
    }
  }
  return null;
}

/*
 * `initialsOf` and `avatarColor` used to live here — a third hand-rolled copy of what
 * `components/ui/person-avatar.tsx` already does, and one that could not draw a photo.
 * Every share surface now uses `PersonAvatar`, so the same person is the same mark in
 * the panel, the tree's Shared column and the conversation header.
 */

/** Does this input look like an address we could invite as a guest? */
export function parseEmail(input: string): string | null {
  const value = input.trim().toLowerCase();
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? value : null;
}

/**
 * A refusal, told apart from a failure.
 *
 * `FORBIDDEN` coming back from a write the panel offered means the viewer's standing
 * changed underneath them — §3.2 K33. The panel then drops to read-only rather than
 * leaving controls that will refuse again. Everything else is an ordinary error and
 * only wants a message.
 *
 * Narrowed with `in`, never a cast: a tRPC error's `data` is genuinely unknown here,
 * and an assertion would turn a shape change into a runtime crash instead of a
 * compile error.
 */
export function refusalMessage(err: unknown): string | null {
  if (typeof err !== 'object' || err === null || !('data' in err)) return null;
  const data = err.data;
  if (typeof data !== 'object' || data === null || !('code' in data)) return null;
  if (data.code !== 'FORBIDDEN') return null;
  return 'message' in err && typeof err.message === 'string' && err.message
    ? err.message
    : 'You can no longer change who can see this.';
}

export function errorMessage(err: unknown, fallback: string): string {
  return err instanceof Error && err.message ? err.message : fallback;
}

// =============================================================================
// ASKING FOR ACCESS
// =============================================================================

/**
 * One person waiting on an answer, as an approver sees them.
 *
 * Derived, and the derivation matters more here than anywhere else on this file: the
 * route filters its rows by the same `share` check the DECISION makes, so what comes
 * back is already "requests you may actually decide". A declared shape would invite a
 * second, client-side idea of who may approve — which is exactly the two-gates-that-
 * disagree bug this whole subsystem exists to remove.
 */
export type AccessRequest = RouterOutputs['files']['pendingAccessRequests'][number];

/**
 * A requested role, as a rung the invite field can actually offer.
 *
 * `requestedRole` is a `GrantRole`, which has two rungs the share ladder does not:
 * `owner` is a fact about the document rather than a grant, and neither it nor a role
 * written by some other route belongs in a control that writes `grantAccess`. Narrowed
 * with `includes`, never a cast — a cast here would put a value in the field that the
 * mutation's own zod enum then refuses, at the moment somebody presses Share.
 */
export function shareableRole(role: string): ShareableRole {
  const found = ROLE_LADDER.find((rung) => rung === role);
  return found ?? 'viewer';
}

// =============================================================================
// TYPED SHARE LINKS
// =============================================================================

/**
 * One link on a document, as its owner sees it — token included, because they need
 * the URL. Derived, like everything else here: `viewCount` and `lastViewedAt` were
 * added to the route specifically so the revoke confirm could state a number, and a
 * declared shape would have been the place that quietly dropped them again.
 */
/** One person on an agent's share list, as `agentSharing.list` returns them. */
export type AgentShareRow = RouterOutputs['agentSharing']['list']['rows'][number];

export type ShareLink = RouterOutputs['files']['listShareLinks'][number];
export type LinkAudience = ShareLink['audience'];

/**
 * The roles a LINK may confer. Three, and `manager` is not one of them.
 *
 * A link is a bearer credential — whoever holds the URL is whoever the link says they
 * are — and `manager` is the standing that may RE-SHARE. A link that conferred it
 * would let anyone it reached mint more links, and the audience would stop being
 * something its author could describe, let alone revoke. The server's
 * `ShareLinkRole` is the same three for the same reason, which is also what makes
 * `createShareLink`'s "you cannot give away more than you hold" cap hold by
 * construction (services/documents/share-links.ts).
 */
export const LINK_ROLE_LADDER = ['viewer', 'commenter', 'editor'] as const;

export type LinkRole = (typeof LINK_ROLE_LADDER)[number];

/**
 * What each audience ADMITS, said as the set of people rather than as the enum.
 *
 * `org` is the default and it is deliberately not called "Cedar only" or similar: the
 * reader is choosing who may open a URL, and every label here is a description of a
 * crowd. "Specific people" for `email` rather than "Email addresses", because the
 * addresses are how it is configured, not what it means.
 */
export const LINK_AUDIENCE_LABEL: Record<LinkAudience, string> = {
  org: 'Anyone at Cedar',
  anyone: 'Anyone with the link',
  email: 'Specific people',
  domain: 'Anyone at a domain',
};

/**
 * The audiences, narrowest first — which is also the order they are offered in.
 *
 * Written out rather than read off the label map with `Object.keys(...) as
 * LinkAudience[]`: that cast is exactly the kind this codebase bans, because it would
 * keep compiling the day the server grew a fifth audience the panel cannot render.
 * Declared as a tuple, adding one to `ShareLinkAudience` without adding it here is a
 * type error in `LINK_AUDIENCE_LABEL` instead.
 */
export const LINK_AUDIENCES: readonly LinkAudience[] = ['org', 'anyone', 'email', 'domain'];

/** The audiences that need `audienceValues`, and would admit NOBODY without them. */
export function audienceNeedsValues(audience: LinkAudience): boolean {
  return audience === 'email' || audience === 'domain';
}

/**
 * The narrowest useful link, not the widest one.
 *
 * A share control's defaults are the setting most links will actually ship with,
 * because the common path is one click on the audience and then Copy. Defaulting to
 * `anyone` / never-expires — which is what `documents.public_token` WAS, by
 * construction — makes publishing to the open internet the thing that happens when
 * nobody thinks about it. Thirty days is the other half: a link that outlives its
 * errand is the one still working a year later, and nobody ever goes back to revoke
 * a link that is not in their way.
 */
export const LINK_DEFAULTS = { audience: 'org', role: 'viewer', days: 30 } as const;

/** How long a new link lasts. `null` is "no expiry", which is a decision, not a blank. */
export const LINK_EXPIRY_CHOICES: ReadonlyArray<{ days: number | null; label: string }> = [
  { days: 7, label: 'In 7 days' },
  { days: 30, label: 'In 30 days' },
  { days: 90, label: 'In 90 days' },
  { days: null, label: 'Never' },
];

export function expiryFromDays(days: number | null): Date | undefined {
  if (days === null) return undefined;
  return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}

/**
 * A timestamp that has crossed a serialiser.
 *
 * superjson hands these back as real `Date`s, and the derived type says so — but the
 * value is read here rather than asserted, because a route that ever loses its
 * transformer would turn every one of them into a string, and a `.getTime()` on a
 * string is a crash in the panel rather than a red squiggle at the keyboard.
 */
export function asDate(value: Date | string | null): Date | null {
  if (value === null) return null;
  const date = value instanceof Date ? value : new Date(value);
  return Number.isNaN(date.getTime()) ? null : date;
}

/**
 * Whether a link still confers anything.
 *
 * `listShareLinks` returns EXPIRED links on purpose — a lapsed link is a thing its
 * owner still has to see in order to revoke or replace it — so "is link sharing on"
 * is a question about the LIVE ones, and a document whose only link expired on
 * Tuesday reads `Off`, which is what it is.
 */
export function isLinkLive(link: Pick<ShareLink, 'expiresAt'>, now = Date.now()): boolean {
  const expires = asDate(link.expiresAt);
  return expires === null || expires.getTime() > now;
}

/** The URL a link is redeemed at. `/share/:token`, the route the app already serves. */
export function shareLinkUrl(token=[redacted], origin: string): string {
  return `${origin}/share/${token}`;
}

/**
 * How many people have used this, in words.
 *
 * Zero is spelled out rather than counted because "opened 0 times" is a sentence
 * nobody writes, and one is spelled "once" for the same reason. The number matters:
 * it is the difference between a link nobody used, which costs nothing to kill, and
 * somebody's working reference.
 */
export function viewsPhrase(viewCount: number): string {
  if (viewCount <= 0) return 'not opened';
  if (viewCount === 1) return 'opened once';
  return `opened ${viewCount} times`;
}

/**
 * What revoking this link DOES, stated before the click (R2).
 *
 * The count comes from `document_share_views` through the route, so it is the number
 * of real opens rather than an estimate — and the sentence changes shape at zero,
 * where "anyone using it" would be describing nobody.
 */
export function revokeConsequence(viewCount: number, linkCount = 1): string {
  if (linkCount > 1) {
    return viewCount <= 0
      ? `None of these ${linkCount} links has been opened. They stop working immediately.`
      : `These ${linkCount} links have been opened ${viewCount} times between them. ` +
          'Anyone using them loses access immediately.';
  }
  if (viewCount <= 0) return 'Nobody has opened this link. It stops working immediately.';
  if (viewCount === 1) {
    return 'This link has been opened once. Anyone using it loses access immediately.';
  }
  return `This link has been opened ${viewCount} times. Anyone using it loses access immediately.`;
}