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.`;
}