pi-roundtable 0.9.12 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/docs/migration-0.9.md +3 -1
  3. package/docs/plugins.md +146 -21
  4. package/examples/fake-surface.test.ts +137 -0
  5. package/examples/fake-surface.ts +71 -14
  6. package/package.json +1 -1
  7. package/src/cli/checks/identity.ts +73 -5
  8. package/src/core/agents/agent-claim.ts +12 -0
  9. package/src/core/agents/team-turns.ts +11 -4
  10. package/src/core/builtin/agent-server.ts +6 -0
  11. package/src/core/config/access.ts +23 -3
  12. package/src/core/config/adapters.ts +11 -0
  13. package/src/core/config/schema.ts +14 -0
  14. package/src/core/define-roundtable.ts +1 -0
  15. package/src/core/define.ts +44 -6
  16. package/src/core/discord/card-audience.ts +104 -7
  17. package/src/core/discord/discord-owners.ts +19 -13
  18. package/src/core/discord/owner-card-types.ts +73 -0
  19. package/src/core/discord/owner-cards.ts +56 -77
  20. package/src/core/domain/conversation.ts +7 -0
  21. package/src/core/domain/ports.ts +4 -1
  22. package/src/core/holds.ts +60 -0
  23. package/src/core/i18n/cards.ts +3 -0
  24. package/src/core/identity/access-policy.ts +65 -2
  25. package/src/core/identity/caching-principal-store.ts +2 -2
  26. package/src/core/identity/identity-links.ts +96 -0
  27. package/src/core/identity/identity-plugin.ts +10 -3
  28. package/src/core/identity/identity-schema.ts +3 -1
  29. package/src/core/identity/identity-service.ts +5 -3
  30. package/src/core/identity/principal-store.ts +59 -142
  31. package/src/core/identity/principal-tables.ts +10 -2
  32. package/src/core/identity/principal-types.ts +146 -0
  33. package/src/core/interactions/prompts.ts +47 -3
  34. package/src/core/modules/schedules/precheck-tools.ts +115 -20
  35. package/src/core/routing/conversation-turns.ts +4 -1
  36. package/src/core/runtime/conversation-sessions.ts +8 -0
  37. package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
  38. package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
  39. package/src/core/runtime/extensions/gate-refusals.ts +133 -0
  40. package/src/core/runtime/extensions/held-calls.ts +91 -0
  41. package/src/core/runtime/extensions/hold-lookups.ts +108 -0
  42. package/src/core/runtime/pi-agent-runtime.ts +7 -3
  43. package/src/core/runtime/prompt-slot.ts +10 -2
  44. package/src/core/runtime/session-factory.ts +1 -0
  45. package/src/core/services.ts +11 -0
  46. package/src/core/sessions.ts +7 -0
  47. package/src/core/testing/surface-contract-support.ts +194 -0
  48. package/src/core/testing/surface-contract.ts +54 -120
  49. package/src/core/tool-turn.ts +5 -1
  50. package/src/index.ts +7 -0
@@ -37,18 +37,45 @@ export interface AccessTier {
37
37
  everyone?: boolean | readonly string[];
38
38
  }
39
39
 
40
+ /** Whether someone the rules admit gets a principal at first contact (`admitted`), or only already linked identities are served (`linked`). */
41
+ export type Provisioning = "admitted" | "linked";
42
+
43
+ /** The provisioning mode for every surface, or per surface; a surface a map leaves out is `linked`, so a misspelled surface name cannot admit everyone. */
44
+ export type ProvisioningRule =
45
+ | Provisioning
46
+ | Readonly<Record<string, Provisioning>>;
47
+
48
+ /** The mode that applies to a contact on this surface. */
49
+ export function provisioningOf(
50
+ rule: ProvisioningRule,
51
+ surface: string,
52
+ ): Provisioning {
53
+ if (typeof rule === "string") return rule;
54
+ return (Object.hasOwn(rule, surface) ? rule[surface] : undefined) ?? "linked";
55
+ }
56
+
40
57
  /** Who the host serves, and at which tier. */
41
58
  export interface AccessRules {
42
59
  /** The owners, the first of them the primary owner. The owner tier comes only from here or the CLI, never from a role a surface reports. */
43
60
  owners: readonly AccessOwner[];
44
61
  admins?: AccessTier;
45
62
  members?: AccessTier;
46
- /** `admitted`: someone the rules admit gets a principal at first contact, and a 0.8 speaker claims theirs; `linked`: only identities already linked, such as by the CLI or the configuration, are served. Owners are never admitted. */
47
- provisioning: "admitted" | "linked";
63
+ /** `admitted`: someone the rules admit gets a principal at first contact, and a 0.8 speaker claims theirs; `linked`: only identities already linked, such as by the CLI or the configuration, are served. A map gives a mode per surface, such as `{ web: "admitted", discord: "linked" }`, and leaves an unnamed surface `linked`. Owners are never admitted. */
64
+ provisioning: ProvisioningRule;
48
65
  /** How many days a principal whose tier came only from the rules on contact may go unseen before their background turns are refused. */
49
66
  backgroundStaleDays: number;
67
+ /** The identity providers whose people may link their own account through `IDENTITY_LINKS`; without it that service is not provided. */
68
+ selfLink?: SelfLinkRules;
69
+ }
70
+
71
+ /** Which providers' identities a person may link to their own principal themselves. */
72
+ export interface SelfLinkRules {
73
+ providers: readonly string[];
50
74
  }
51
75
 
76
+ /** Providers whose identities are credentials or carried-over ids, never a person's own account to link. */
77
+ const NOT_SELF_LINKABLE = ["token", "legacy"];
78
+
52
79
  const holds = (
53
80
  tier: AccessTier | undefined,
54
81
  facts: ActorFacts,
@@ -77,6 +104,15 @@ export function factsTier(
77
104
  return undefined;
78
105
  }
79
106
 
107
+ /** How the rules provision a contact with these facts, by the surface they came through. */
108
+ export function provisioningFor(
109
+ rules: Pick<AccessRules, "provisioning">,
110
+ facts: ActorFacts,
111
+ conversation?: ChannelKey,
112
+ ): Provisioning {
113
+ return provisioningOf(rules.provisioning, surfaceOf(facts, conversation));
114
+ }
115
+
80
116
  /** The configured owner whose identities include these facts', if any. */
81
117
  export function ownerOfFacts(
82
118
  rules: Pick<AccessRules, "owners">,
@@ -139,6 +175,12 @@ export function checkAccessRules(rules: AccessRules): AccessRules {
139
175
  seenIdentity.set(identity, n);
140
176
  });
141
177
  });
178
+ if (typeof rules.provisioning !== "string")
179
+ for (const [surface, mode] of Object.entries(rules.provisioning))
180
+ if (surface.trim() === "" || (mode !== "admitted" && mode !== "linked"))
181
+ throw new ConfigError(
182
+ `config access.provisioning.${surface}: expected "admitted" or "linked" for a named surface, got ${JSON.stringify(mode)}. Write it like { web: "admitted", discord: "linked" }.`,
183
+ );
142
184
  checkTier(rules.admins, "access.admins");
143
185
  checkTier(rules.members, "access.members");
144
186
  if (
@@ -150,6 +192,27 @@ export function checkAccessRules(rules: AccessRules): AccessRules {
150
192
  throw new ConfigError(
151
193
  `config access.backgroundStaleDays: expected a positive whole number of days, got ${JSON.stringify(rules.backgroundStaleDays)}.`,
152
194
  );
195
+ if (rules.selfLink) {
196
+ const { providers } = rules.selfLink;
197
+ if (providers.length === 0)
198
+ throw new ConfigError(
199
+ 'config access.selfLink.providers: expected at least one provider, such as ["discord"]. Remove selfLink to switch self-linking off.',
200
+ );
201
+ providers.forEach((provider, i) => {
202
+ if (provider.trim() === "")
203
+ throw new ConfigError(
204
+ `config access.selfLink.providers[${i}]: expected a provider such as discord, got ${JSON.stringify(provider)}.`,
205
+ );
206
+ if (provider === "oidc")
207
+ throw new ConfigError(
208
+ `config access.selfLink.providers[${i}]: an OIDC provider is written oidc:<base64url issuer>, not oidc.`,
209
+ );
210
+ if (NOT_SELF_LINKABLE.includes(provider))
211
+ throw new ConfigError(
212
+ `config access.selfLink.providers[${i}]: ${provider} identities are not a person's own account; list the provider of the account people sign in with, such as discord.`,
213
+ );
214
+ });
215
+ }
153
216
  return rules;
154
217
  }
155
218
 
@@ -86,8 +86,8 @@ export class CachingPrincipalStore implements PrincipalStore {
86
86
  admit(identity: IdentityRef, displayName: string, id?: string) {
87
87
  return this.#write(this.#store.admit(identity, displayName, id));
88
88
  }
89
- unlink(provider: string, subject: string) {
90
- return this.#write(this.#store.unlink(provider, subject));
89
+ unlink(provider: string, subject: string, source?: LinkSource) {
90
+ return this.#write(this.#store.unlink(provider, subject, source));
91
91
  }
92
92
  grant(principalId: string, role: Tier, source: RoleSource) {
93
93
  return this.#write(this.#store.grant(principalId, role, source));
@@ -0,0 +1,96 @@
1
+ import { IdentityError } from "../domain/errors.ts";
2
+ import { freeze } from "../freeze.ts";
3
+ import type { Logger } from "../log.ts";
4
+ import type { SelfLinkRules } from "./access-policy.ts";
5
+ import { identityOf, parseIdentity } from "./actor-facts.ts";
6
+ import {
7
+ type IdentityLink,
8
+ type IdentityRef,
9
+ type PrincipalStore,
10
+ SYSTEM_PRINCIPAL,
11
+ } from "./principal-store.ts";
12
+
13
+ /**
14
+ * The links people make to their own principal: a plugin that proved who someone is, such as one
15
+ * that checked a code against a signed-in person, links the account that person controls. Provided
16
+ * as `IDENTITY_LINKS` only when the configuration lists the providers in `access.selfLink`;
17
+ * `IDENTITY` stays read-only. A link made here has the source `self`, and only such a link is
18
+ * unlinked here.
19
+ */
20
+ export interface IdentityLinks {
21
+ /**
22
+ * Links the identity, written `<provider>:<subject>`, to the principal, and gives the link.
23
+ * Linking it again to the same principal changes nothing, also when the principal has since been
24
+ * granted the owner role: a link made before keeps. Throws IdentityError for a provider
25
+ * `access.selfLink` does not list, the system principal, a principal that holds the owner role
26
+ * or does not exist, text that is no identity, and an identity linked to another principal.
27
+ */
28
+ link(principalId: string, identity: string): Promise<IdentityLink>;
29
+ /**
30
+ * Unlinks the identity; true when a self link was removed, false when there was none to remove
31
+ * (it was linked to no one, or its link went away meanwhile). Throws IdentityError for a link
32
+ * another source made, such as the configuration or the CLI: those are not unlinked here.
33
+ */
34
+ unlink(identity: string): Promise<boolean>;
35
+ }
36
+
37
+ /** The self-link service over the principal store, for the providers the configuration lists. */
38
+ export function identityLinks(
39
+ store: PrincipalStore,
40
+ rules: SelfLinkRules,
41
+ logger: Logger,
42
+ ): IdentityLinks {
43
+ const parse = (identity: string): IdentityRef => {
44
+ const ref = parseIdentity(identity);
45
+ if (!ref)
46
+ throw new IdentityError(
47
+ `${JSON.stringify(identity)} is not an identity written <provider>:<subject>`,
48
+ );
49
+ return ref;
50
+ };
51
+ return Object.freeze({
52
+ async link(principalId, identity) {
53
+ const ref = parse(identity);
54
+ if (!rules.providers.includes(ref.provider))
55
+ throw new IdentityError(
56
+ `${ref.provider} identities may not be linked by their person: access.selfLink lists ${rules.providers.join(", ")}`,
57
+ );
58
+ if (principalId === SYSTEM_PRINCIPAL)
59
+ throw new IdentityError(
60
+ `the host's own principal "${SYSTEM_PRINCIPAL}" has no identities`,
61
+ );
62
+ const link = await store.link(principalId, ref, "self");
63
+ logger.info(
64
+ {
65
+ principal: principalId,
66
+ identity: identityOf(ref),
67
+ source: link.source,
68
+ },
69
+ "an identity was linked by its person",
70
+ );
71
+ return freeze(structuredClone(link));
72
+ },
73
+ async unlink(identity) {
74
+ const ref = parse(identity);
75
+ // The cached read only names the principal in the log: it may be up to 30 s old, so it
76
+ // decides nothing. Only a self link goes: one the configuration or the CLI took over is kept.
77
+ const before = await store.identity(ref.provider, ref.subject);
78
+ const removed = await store.unlink(ref.provider, ref.subject, "self");
79
+ if (removed) {
80
+ logger.info(
81
+ { principal: before?.principalId, identity: identityOf(ref) },
82
+ "an identity was unlinked by its person",
83
+ );
84
+ return true;
85
+ }
86
+ // Nothing went: read again (a write through the store drops its cached reads) to tell a link
87
+ // another source took over from one that is gone.
88
+ const now = await store.identity(ref.provider, ref.subject);
89
+ if (now && now.source !== "self")
90
+ throw new IdentityError(
91
+ `${identityOf(ref)} was linked by ${now.source}, not by its person; it is not unlinked here`,
92
+ );
93
+ return false;
94
+ },
95
+ } satisfies IdentityLinks);
96
+ }
@@ -1,6 +1,7 @@
1
1
  import type { RoundtablePlugin } from "../plugin.ts";
2
- import { IDENTITY } from "../services.ts";
2
+ import { IDENTITY, IDENTITY_LINKS } from "../services.ts";
3
3
  import type { AccessRules } from "./access-policy.ts";
4
+ import { identityLinks } from "./identity-links.ts";
4
5
  import { type BackfillSummary, identityMigrations } from "./identity-schema.ts";
5
6
  import { PgIdentityService } from "./identity-service.ts";
6
7
  import { identityView } from "./identity-view.ts";
@@ -29,7 +30,8 @@ export function backfillLine(summary: BackfillSummary): string {
29
30
  * The principals, their identity links, and their roles: the tables, the backfill that makes a
30
31
  * principal of every person 0.8 stored, and the identity service over them provided as
31
32
  * `IDENTITY`, read-only, its configured owners and the plugins' declared identities synced at
32
- * setup. It runs first, so every later plugin may read it.
33
+ * setup. When the rules list `selfLink` providers it also provides `IDENTITY_LINKS`. It runs
34
+ * first, so every later plugin may read them.
33
35
  */
34
36
  export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
35
37
  const { rules } = options;
@@ -40,7 +42,7 @@ export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
40
42
  migrations: identityMigrations(rules, (made) => {
41
43
  summary = made;
42
44
  }),
43
- provides: [IDENTITY],
45
+ provides: rules.selfLink ? [IDENTITY, IDENTITY_LINKS] : [IDENTITY],
44
46
  setup: async ({ database, services, logger }) => {
45
47
  if (summary) logger.info(backfillLine(summary));
46
48
  const identity = new PgIdentityService(
@@ -50,6 +52,11 @@ export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
50
52
  );
51
53
  await identity.syncConfig();
52
54
  services.provide(IDENTITY, identityView(identity));
55
+ if (rules.selfLink)
56
+ services.provide(
57
+ IDENTITY_LINKS,
58
+ identityLinks(identity.store, rules.selfLink, logger),
59
+ );
53
60
  return {};
54
61
  },
55
62
  };
@@ -10,6 +10,7 @@ import {
10
10
  } from "./principal-store.ts";
11
11
  import {
12
12
  PRINCIPAL_PLUGIN_LINKS,
13
+ PRINCIPAL_SELF_LINKS,
13
14
  PRINCIPAL_TABLES,
14
15
  PRINCIPALS_CLAIMABLE,
15
16
  } from "./principal-tables.ts";
@@ -259,7 +260,7 @@ export async function backfillPrincipals(
259
260
 
260
261
  /**
261
262
  * The identity plugin's migrations: its tables once, the claimable column and the plugins' link
262
- * source once each (apart, so a database an earlier build made the tables on gets them too), then
263
+ * source and the people's own link source once each (apart, so a database an earlier build made the tables on gets them too), then
263
264
  * the backfill at every boot, which hands its summary to `report`.
264
265
  */
265
266
  export function identityMigrations(
@@ -270,6 +271,7 @@ export function identityMigrations(
270
271
  { name: "principals", up: PRINCIPAL_TABLES },
271
272
  { name: "principals-claimable", up: PRINCIPALS_CLAIMABLE },
272
273
  { name: "principals-plugin-links", up: PRINCIPAL_PLUGIN_LINKS },
274
+ { name: "principals-self-links", up: PRINCIPAL_SELF_LINKS },
273
275
  {
274
276
  name: "backfill",
275
277
  runs: "every-boot",
@@ -6,6 +6,7 @@ import {
6
6
  type AccessRules,
7
7
  checkAccessRules,
8
8
  factsTier,
9
+ provisioningFor,
9
10
  } from "./access-policy.ts";
10
11
  import {
11
12
  type ActorFacts,
@@ -45,7 +46,7 @@ export interface IdentityService {
45
46
  * linked identity is its principal's; an unlinked one first claims the principal of its 0.8
46
47
  * id (`legacyId`) when the backfill made it and no identity was ever linked to it, unless it
47
48
  * holds the owner role, taking the facts' name when the backfill named it by its id, and is
48
- * otherwise admitted as a new principal when the rules give it a tier. Both happen only when `provisioning` is `admitted`; under `linked` only identities
49
+ * otherwise admitted as a new principal when the rules give it a tier. Both happen only when `provisioning` is `admitted` on the contact's surface; under `linked` only identities
49
50
  * already linked are served. A disabled principal is no one. The tier is the higher of the
50
51
  * principal's lasting roles and what the rules give the facts on this contact.
51
52
  */
@@ -392,7 +393,8 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
392
393
  facts: ActorFacts,
393
394
  conversation: ChannelKey | undefined,
394
395
  ): Promise<{ principalId: string; tier: Tier } | undefined> {
395
- if (this.#rules.provisioning !== "admitted") return undefined;
396
+ if (provisioningFor(this.#rules, facts, conversation) !== "admitted")
397
+ return undefined;
396
398
  // An owner's identity, or a plugin's, is its principal's alone, linked at boot: unlinked
397
399
  // meanwhile, it is no one until then.
398
400
  if (this.#bootIdentities.has(identityOf(facts))) return undefined;
@@ -434,7 +436,7 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
434
436
  let link = await this.store.identity(ref.provider, ref.subject);
435
437
  if (
436
438
  !link &&
437
- this.#rules.provisioning === "admitted" &&
439
+ provisioningFor(this.#rules, facts, conversation) === "admitted" &&
438
440
  !this.#bootIdentities.has(identityOf(ref))
439
441
  ) {
440
442
  if (assessed === facts.legacyId)
@@ -1,9 +1,33 @@
1
1
  import type { SQL } from "bun";
2
- import type { Pronouns } from "../config/config.ts";
3
2
  import { IdentityError } from "../domain/errors.ts";
4
3
  import type { Tier } from "../speakers.ts";
4
+ import type {
5
+ IdentityLink,
6
+ IdentityRef,
7
+ LinkSource,
8
+ NewPrincipal,
9
+ PrincipalRecord,
10
+ PrincipalStore,
11
+ Pronouns,
12
+ RoleGrant,
13
+ RoleHolder,
14
+ RoleSource,
15
+ } from "./principal-types.ts";
5
16
  import { isPrincipalId, newPrincipalId } from "./ulid.ts";
6
17
 
18
+ export type {
19
+ IdentityLink,
20
+ IdentityRef,
21
+ LinkSource,
22
+ NewPrincipal,
23
+ Principal,
24
+ PrincipalRecord,
25
+ PrincipalStore,
26
+ Pronouns,
27
+ RoleGrant,
28
+ RoleHolder,
29
+ RoleSource,
30
+ } from "./principal-types.ts";
7
31
  /** The principal the host's own turns run as: an ops report, a webhook's. It has no identity, cannot sign in, and keeps no memory. */
8
32
  export const SYSTEM_PRINCIPAL = "system";
9
33
 
@@ -16,41 +40,6 @@ export const LEGACY_PROVIDER = "legacy";
16
40
  /** 0.8's speaker id for the owner writing through an outside agent over MCP: the primary owner. */
17
41
  export const LEGACY_REMOTE_SPEAKER = "remote-mcp";
18
42
 
19
- export type { Pronouns };
20
-
21
- /** A person the host serves, whichever surface they reach it through. */
22
- export interface Principal {
23
- /** Opaque: a speaker id carried over from 0.8, or `p_<ulid>` for one made since. */
24
- id: string;
25
- displayName: string;
26
- pronouns?: Pronouns;
27
- disabled: boolean;
28
- }
29
-
30
- /** A principal as stored, with when it was made and when it was last seen. */
31
- export interface PrincipalRecord extends Principal {
32
- locale?: string;
33
- timeZone?: string;
34
- createdAt: Date;
35
- lastSeenAt?: Date;
36
- /** The tier it was last seen at; it only ever lowers what a background turn may do, never grants. */
37
- lastTier?: Tier;
38
- /** Whether the person of its 0.8 speaker id may still claim it: only one the backfill made, until any identity is linked to it. */
39
- claimable: boolean;
40
- }
41
-
42
- /** An identity on one provider: `discord` and a user id, `oidc:<issuer>` and a subject, `token` and a name. */
43
- export interface IdentityRef {
44
- provider: string;
45
- subject: string;
46
- }
47
-
48
- /**
49
- * How an identity came to be linked: by the configuration, a plugin declaring it
50
- * (`RoundtablePlugin.identities`), the CLI, admission on first contact, or a 0.8 speaker id claimed.
51
- */
52
- export type LinkSource = "config" | "plugin" | "cli" | "jit" | "legacy";
53
-
54
43
  /** Which source a link made again by another becomes: the configuration's over all, a plugin's over the rest. */
55
44
  const LINK_RANK: Readonly<Record<LinkSource, number>> = {
56
45
  config: 2,
@@ -58,106 +47,9 @@ const LINK_RANK: Readonly<Record<LinkSource, number>> = {
58
47
  cli: 0,
59
48
  jit: 0,
60
49
  legacy: 0,
50
+ self: 0,
61
51
  };
62
52
 
63
- export interface IdentityLink extends IdentityRef {
64
- principalId: string;
65
- source: LinkSource;
66
- linkedAt: Date;
67
- }
68
-
69
- /** Where a role came from. The configuration's are synced at every boot; a CLI grant stays until the CLI revokes it. */
70
- export type RoleSource = "config" | "cli";
71
-
72
- export interface RoleGrant {
73
- role: Tier;
74
- source: RoleSource;
75
- grantedAt: Date;
76
- }
77
-
78
- /** A principal holding a role, and where the role came from. */
79
- export interface RoleHolder {
80
- principalId: string;
81
- source: RoleSource;
82
- }
83
-
84
- export interface NewPrincipal {
85
- /** The id to keep, for a principal carried over from 0.8; otherwise a new `p_` id is made. */
86
- id?: string;
87
- displayName: string;
88
- pronouns?: Pronouns;
89
- }
90
-
91
- /** The principals, their linked identities, and their lasting roles. */
92
- export interface PrincipalStore {
93
- get(id: string): Promise<PrincipalRecord | undefined>;
94
- list(): Promise<PrincipalRecord[]>;
95
- /** Refuses an id already taken, and the system principal's. */
96
- create(input: NewPrincipal): Promise<PrincipalRecord>;
97
- /** Changes the name or the pronouns; `pronouns: null` clears them. */
98
- update(
99
- id: string,
100
- change: { displayName?: string; pronouns?: Pronouns | null },
101
- ): Promise<PrincipalRecord | undefined>;
102
- /**
103
- * Links the identity to the principal; linking it again to the same one changes nothing, but
104
- * that the configuration linking it makes the link the configuration's, and a plugin linking it
105
- * makes one the CLI, a first contact, or a claim made the plugin's; to another one is refused. A
106
- * principal with any identity linked is no longer claimable, even after the identity is
107
- * unlinked, except by a plugin's link: a plugin's credential is not the person's own identity,
108
- * so it leaves their 0.8 claim to them.
109
- */
110
- link(
111
- principalId: string,
112
- identity: IdentityRef,
113
- source: LinkSource,
114
- ): Promise<IdentityLink>;
115
- /**
116
- * The identity's link, claiming the principal for it when the identity is still unlinked and
117
- * the principal claimable and not an owner; undefined when it may not be claimed. A principal
118
- * is claimed at most once. A claim names the principal `displayName`, when given, while its
119
- * name is still its id, as the backfill names someone it knows no name of; a real name stays.
120
- */
121
- claim(
122
- principalId: string,
123
- identity: IdentityRef,
124
- displayName?: string,
125
- ): Promise<IdentityLink | undefined>;
126
- /**
127
- * The identity's link, making a new `p_` principal for it, of `id` when given, when it is still
128
- * unlinked; however many contacts admit it at once, one principal is made.
129
- */
130
- admit(
131
- identity: IdentityRef,
132
- displayName: string,
133
- id?: string,
134
- ): Promise<IdentityLink>;
135
- /** Whether there was a link to remove. */
136
- unlink(provider: string, subject: string): Promise<boolean>;
137
- identity(
138
- provider: string,
139
- subject: string,
140
- ): Promise<IdentityLink | undefined>;
141
- identitiesOf(principalId: string): Promise<IdentityLink[]>;
142
- /** Every link made by `source`, such as the configuration's. */
143
- linksFrom(source: LinkSource): Promise<IdentityLink[]>;
144
- /** Grants a lasting role. A CLI grant is kept as the CLI's even when the configuration grants the same role. */
145
- grant(principalId: string, role: Tier, source: RoleSource): Promise<void>;
146
- /** Revokes the role, or only the grant from `source`; whether there was one to revoke. */
147
- revoke(
148
- principalId: string,
149
- role: Tier,
150
- source?: RoleSource,
151
- ): Promise<boolean>;
152
- rolesOf(principalId: string): Promise<RoleGrant[]>;
153
- /** Every principal holding the role. */
154
- holders(role: Tier): Promise<RoleHolder[]>;
155
- disable(id: string): Promise<void>;
156
- enable(id: string): Promise<void>;
157
- /** Records that the principal was seen at `at` (default now), at this tier, or null when refused. */
158
- touch(id: string, tier: Tier | null, at?: Date): Promise<void>;
159
- }
160
-
161
53
  interface PrincipalRow {
162
54
  id: string;
163
55
  display_name: string;
@@ -302,14 +194,29 @@ export class PgPrincipalStore implements PrincipalStore {
302
194
  RETURNING *`;
303
195
  return linkOf(owned[0] ?? existing);
304
196
  }
197
+ // A person's own link is refused to an owner, judged in this write rather than on a cached read.
305
198
  const spent =
306
199
  source === "plugin"
307
200
  ? await tx`SELECT id FROM principals WHERE id = ${principalId}`
308
- : await tx`
309
- UPDATE principals SET claimable = false WHERE id = ${principalId}
310
- RETURNING id`;
311
- if (spent.length === 0)
201
+ : source === "self"
202
+ ? await tx`
203
+ UPDATE principals SET claimable = false
204
+ WHERE id = ${principalId} AND NOT EXISTS (
205
+ SELECT 1 FROM principal_roles WHERE principal_id = ${principalId} AND role = 'owner'
206
+ )
207
+ RETURNING id`
208
+ : await tx`
209
+ UPDATE principals SET claimable = false WHERE id = ${principalId}
210
+ RETURNING id`;
211
+ if (spent.length === 0) {
212
+ const present =
213
+ await tx`SELECT id FROM principals WHERE id = ${principalId}`;
214
+ if (present.length > 0)
215
+ throw new IdentityError(
216
+ `principal ${principalId} is an owner; owners' identities are linked in access.owners or with roundtable principal link`,
217
+ );
312
218
  throw new IdentityError(`there is no principal ${principalId}`);
219
+ }
313
220
  const made: IdentityRow[] = await tx`
314
221
  INSERT INTO principal_identities (provider, subject, principal_id, source)
315
222
  VALUES (${provider}, ${subject}, ${principalId}, ${source})
@@ -377,10 +284,20 @@ export class PgPrincipalStore implements PrincipalStore {
377
284
  });
378
285
  }
379
286
 
380
- async unlink(provider: string, subject: string): Promise<boolean> {
381
- const rows = await this.#sql`
382
- DELETE FROM principal_identities WHERE provider = ${provider} AND subject = ${subject}
383
- RETURNING provider`;
287
+ async unlink(
288
+ provider: string,
289
+ subject: string,
290
+ source?: LinkSource,
291
+ ): Promise<boolean> {
292
+ const rows =
293
+ source === undefined
294
+ ? await this.#sql`
295
+ DELETE FROM principal_identities WHERE provider = ${provider} AND subject = ${subject}
296
+ RETURNING provider`
297
+ : await this.#sql`
298
+ DELETE FROM principal_identities
299
+ WHERE provider = ${provider} AND subject = ${subject} AND source = ${source}
300
+ RETURNING provider`;
384
301
  return rows.length > 0;
385
302
  }
386
303
 
@@ -44,10 +44,18 @@ export const PRINCIPALS_CLAIMABLE = async (sql: SQL): Promise<void> => {
44
44
  await sql`ALTER TABLE principals ADD COLUMN IF NOT EXISTS claimable boolean NOT NULL DEFAULT false`;
45
45
  };
46
46
 
47
- /** Links a plugin declares: their own source, beside the four the tables were made with. */
47
+ /** Links a plugin declares: their own source, beside the four the tables were made with. It accepts `self` too, so a rerun on a database that holds self links does not fail. */
48
48
  export const PRINCIPAL_PLUGIN_LINKS = async (sql: SQL): Promise<void> => {
49
49
  await sql`ALTER TABLE principal_identities DROP CONSTRAINT IF EXISTS principal_identities_source_check`;
50
50
  await sql`
51
51
  ALTER TABLE principal_identities ADD CONSTRAINT principal_identities_source_check
52
- CHECK (source IN ('config', 'plugin', 'cli', 'jit', 'legacy'))`;
52
+ CHECK (source IN ('config', 'plugin', 'cli', 'jit', 'legacy', 'self'))`;
53
+ };
54
+
55
+ /** Links people make themselves through `IDENTITY_LINKS`: their own source, beside the five before it. */
56
+ export const PRINCIPAL_SELF_LINKS = async (sql: SQL): Promise<void> => {
57
+ await sql`ALTER TABLE principal_identities DROP CONSTRAINT IF EXISTS principal_identities_source_check`;
58
+ await sql`
59
+ ALTER TABLE principal_identities ADD CONSTRAINT principal_identities_source_check
60
+ CHECK (source IN ('config', 'plugin', 'cli', 'jit', 'legacy', 'self'))`;
53
61
  };