pi-roundtable 0.7.19 → 0.9.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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -0,0 +1,38 @@
1
+ import { type Speaker, type Tier, tierAtLeast } from "../speakers.ts";
2
+ import { type ActorFacts, identityOf } from "./actor-facts.ts";
3
+ import type { Principal, PrincipalRecord } from "./principal-store.ts";
4
+
5
+ /** The speaker of a contact: the id the surface knows them by, their 0.8 id where they have one. */
6
+ export const speakerOf = (
7
+ facts: ActorFacts,
8
+ principalId: string,
9
+ tier: Tier,
10
+ ): Speaker => ({
11
+ id: facts.legacyId ?? identityOf(facts),
12
+ name: facts.name,
13
+ tier,
14
+ principalId,
15
+ });
16
+
17
+ /** The highest of the tiers given, undefined for none. */
18
+ export const highest = (
19
+ tiers: readonly (Tier | undefined)[],
20
+ ): Tier | undefined =>
21
+ tiers.reduce<Tier | undefined>(
22
+ (best, tier) =>
23
+ tier !== undefined && (best === undefined || tierAtLeast(tier, best))
24
+ ? tier
25
+ : best,
26
+ undefined,
27
+ );
28
+
29
+ /** The lower of two tiers. */
30
+ export const lowest = (a: Tier, b: Tier): Tier => (tierAtLeast(a, b) ? b : a);
31
+
32
+ /** A stored principal as the identity service shows it. */
33
+ export const publicOf = (record: PrincipalRecord): Principal => ({
34
+ id: record.id,
35
+ displayName: record.displayName,
36
+ ...(record.pronouns ? { pronouns: record.pronouns } : {}),
37
+ disabled: record.disabled,
38
+ });
@@ -0,0 +1,478 @@
1
+ import type { SQL } from "bun";
2
+ import type { Pronouns } from "../config/config.ts";
3
+ import { IdentityError } from "../domain/errors.ts";
4
+ import type { Tier } from "../speakers.ts";
5
+ import { isPrincipalId, newPrincipalId } from "./ulid.ts";
6
+
7
+ /** 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
+ export const SYSTEM_PRINCIPAL = "system";
9
+
10
+ /**
11
+ * The provider of a 0.8 speaker id that stands for a principal of another id, linked at the
12
+ * upgrade; no surface reports it, so no one resolves through it.
13
+ */
14
+ export const LEGACY_PROVIDER = "legacy";
15
+
16
+ /** 0.8's speaker id for the owner writing through an outside agent over MCP: the primary owner. */
17
+ export const LEGACY_REMOTE_SPEAKER = "remote-mcp";
18
+
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
+ /** Which source a link made again by another becomes: the configuration's over all, a plugin's over the rest. */
55
+ const LINK_RANK: Readonly<Record<LinkSource, number>> = {
56
+ config: 2,
57
+ plugin: 1,
58
+ cli: 0,
59
+ jit: 0,
60
+ legacy: 0,
61
+ };
62
+
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
+ interface PrincipalRow {
162
+ id: string;
163
+ display_name: string;
164
+ pronouns: Pronouns | null;
165
+ locale: string | null;
166
+ time_zone: string | null;
167
+ created_at: Date;
168
+ disabled_at: Date | null;
169
+ last_seen_at: Date | null;
170
+ last_tier: Tier | null;
171
+ claimable: boolean;
172
+ }
173
+
174
+ interface IdentityRow {
175
+ provider: string;
176
+ subject: string;
177
+ principal_id: string;
178
+ source: LinkSource;
179
+ linked_at: Date;
180
+ }
181
+
182
+ interface RoleRow {
183
+ role: Tier;
184
+ source: RoleSource;
185
+ granted_at: Date;
186
+ }
187
+
188
+ function principalOf(row: PrincipalRow): PrincipalRecord {
189
+ return {
190
+ id: row.id,
191
+ displayName: row.display_name,
192
+ ...(row.pronouns === null ? {} : { pronouns: row.pronouns }),
193
+ disabled: row.disabled_at !== null,
194
+ ...(row.locale === null ? {} : { locale: row.locale }),
195
+ ...(row.time_zone === null ? {} : { timeZone: row.time_zone }),
196
+ createdAt: row.created_at,
197
+ ...(row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at }),
198
+ ...(row.last_tier === null ? {} : { lastTier: row.last_tier }),
199
+ claimable: row.claimable,
200
+ };
201
+ }
202
+
203
+ function linkOf(row: IdentityRow): IdentityLink {
204
+ return {
205
+ provider: row.provider,
206
+ subject: row.subject,
207
+ principalId: row.principal_id,
208
+ source: row.source,
209
+ linkedAt: row.linked_at,
210
+ };
211
+ }
212
+
213
+ /** The advisory lock a first contact of this identity holds, so two contacts at once make one link. */
214
+ const identityLock = (identity: IdentityRef) =>
215
+ `pi-roundtable:identity:${identity.provider}\n${identity.subject}`;
216
+
217
+ /** The principal store over the `principals`, `principal_identities`, and `principal_roles` tables. */
218
+ export class PgPrincipalStore implements PrincipalStore {
219
+ readonly #sql: SQL;
220
+
221
+ private constructor(sql: SQL) {
222
+ this.#sql = sql;
223
+ }
224
+
225
+ /** The store over the host's migrated pool. */
226
+ static async attach(sql: SQL): Promise<PgPrincipalStore> {
227
+ return new PgPrincipalStore(sql);
228
+ }
229
+
230
+ async get(id: string): Promise<PrincipalRecord | undefined> {
231
+ const rows: PrincipalRow[] = await this.#sql`
232
+ SELECT * FROM principals WHERE id = ${id}`;
233
+ const [row] = rows;
234
+ return row ? principalOf(row) : undefined;
235
+ }
236
+
237
+ async list(): Promise<PrincipalRecord[]> {
238
+ const rows: PrincipalRow[] = await this.#sql`
239
+ SELECT * FROM principals ORDER BY created_at, id`;
240
+ return rows.map(principalOf);
241
+ }
242
+
243
+ async create(input: NewPrincipal): Promise<PrincipalRecord> {
244
+ const id = input.id ?? newPrincipalId();
245
+ if (id === SYSTEM_PRINCIPAL)
246
+ throw new IdentityError(
247
+ `"${SYSTEM_PRINCIPAL}" is the host's own principal; give another id`,
248
+ );
249
+ const rows: PrincipalRow[] = await this.#sql`
250
+ INSERT INTO principals (id, display_name, pronouns)
251
+ VALUES (${id}, ${input.displayName}, ${input.pronouns ?? null})
252
+ ON CONFLICT (id) DO NOTHING
253
+ RETURNING *`;
254
+ const [row] = rows;
255
+ if (!row) throw new IdentityError(`principal ${id} already exists`);
256
+ return principalOf(row);
257
+ }
258
+
259
+ async update(
260
+ id: string,
261
+ change: { displayName?: string; pronouns?: Pronouns | null },
262
+ ): Promise<PrincipalRecord | undefined> {
263
+ const keepPronouns = change.pronouns === undefined;
264
+ const rows: PrincipalRow[] = await this.#sql`
265
+ UPDATE principals SET
266
+ display_name = COALESCE(${change.displayName ?? null}::text, display_name),
267
+ pronouns = CASE WHEN ${keepPronouns}::boolean THEN pronouns ELSE ${change.pronouns ?? null}::text END
268
+ WHERE id = ${id}
269
+ RETURNING *`;
270
+ const [row] = rows;
271
+ return row ? principalOf(row) : undefined;
272
+ }
273
+
274
+ async link(
275
+ principalId: string,
276
+ identity: IdentityRef,
277
+ source: LinkSource,
278
+ ): Promise<IdentityLink> {
279
+ if (principalId === SYSTEM_PRINCIPAL)
280
+ throw new IdentityError(
281
+ `the host's own principal "${SYSTEM_PRINCIPAL}" has no identities`,
282
+ );
283
+ const { provider, subject } = identity;
284
+ // The same lock, and the same order of writes, as a first contact's claim of this identity,
285
+ // so the two at once queue instead of deadlocking.
286
+ return this.#sql.begin(async (tx) => {
287
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${identityLock(identity)}, 0))`;
288
+ const linked: IdentityRow[] = await tx`
289
+ SELECT * FROM principal_identities WHERE provider = ${provider} AND subject = ${subject}`;
290
+ const [existing] = linked;
291
+ if (existing) {
292
+ if (existing.principal_id !== principalId)
293
+ throw new IdentityError(
294
+ `${provider}:${subject} is already linked to principal ${existing.principal_id}; unlink it first`,
295
+ );
296
+ if (LINK_RANK[source] <= LINK_RANK[existing.source])
297
+ return linkOf(existing);
298
+ // The configuration or a plugin names it now, so it is theirs to unlink.
299
+ const owned: IdentityRow[] = await tx`
300
+ UPDATE principal_identities SET source = ${source}
301
+ WHERE provider = ${provider} AND subject = ${subject}
302
+ RETURNING *`;
303
+ return linkOf(owned[0] ?? existing);
304
+ }
305
+ const spent =
306
+ source === "plugin"
307
+ ? 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)
312
+ throw new IdentityError(`there is no principal ${principalId}`);
313
+ const made: IdentityRow[] = await tx`
314
+ INSERT INTO principal_identities (provider, subject, principal_id, source)
315
+ VALUES (${provider}, ${subject}, ${principalId}, ${source})
316
+ RETURNING *`;
317
+ const [link] = made;
318
+ if (!link)
319
+ throw new IdentityError(`could not link ${provider}:${subject}`);
320
+ return linkOf(link);
321
+ });
322
+ }
323
+
324
+ async claim(
325
+ principalId: string,
326
+ identity: IdentityRef,
327
+ displayName?: string,
328
+ ): Promise<IdentityLink | undefined> {
329
+ const name = displayName?.trim() || null;
330
+ const { provider, subject } = identity;
331
+ return this.#sql.begin(async (tx) => {
332
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${identityLock(identity)}, 0))`;
333
+ const linked: IdentityRow[] = await tx`
334
+ SELECT * FROM principal_identities WHERE provider = ${provider} AND subject = ${subject}`;
335
+ if (linked[0]) return linkOf(linked[0]);
336
+ // Named in the same write that spends the claim, so a claim that fails renames no one.
337
+ const taken = await tx`
338
+ UPDATE principals SET
339
+ claimable = false,
340
+ display_name = CASE WHEN display_name = id AND ${name}::text IS NOT NULL
341
+ THEN ${name}::text ELSE display_name END
342
+ WHERE id = ${principalId} AND claimable AND NOT EXISTS (
343
+ SELECT 1 FROM principal_roles WHERE principal_id = ${principalId} AND role = 'owner'
344
+ )
345
+ RETURNING id`;
346
+ if (taken.length === 0) return undefined;
347
+ const made: IdentityRow[] = await tx`
348
+ INSERT INTO principal_identities (provider, subject, principal_id, source)
349
+ VALUES (${provider}, ${subject}, ${principalId}, 'legacy')
350
+ RETURNING *`;
351
+ return made[0] && linkOf(made[0]);
352
+ });
353
+ }
354
+
355
+ async admit(
356
+ identity: IdentityRef,
357
+ displayName: string,
358
+ id = newPrincipalId(),
359
+ ): Promise<IdentityLink> {
360
+ const { provider, subject } = identity;
361
+ return this.#sql.begin(async (tx) => {
362
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${identityLock(identity)}, 0))`;
363
+ const linked: IdentityRow[] = await tx`
364
+ SELECT * FROM principal_identities WHERE provider = ${provider} AND subject = ${subject}`;
365
+ if (linked[0]) return linkOf(linked[0]);
366
+ if (!isPrincipalId(id))
367
+ throw new IdentityError(`a new principal's id is p_<ulid>, not ${id}`);
368
+ await tx`INSERT INTO principals (id, display_name) VALUES (${id}, ${displayName})`;
369
+ const made: IdentityRow[] = await tx`
370
+ INSERT INTO principal_identities (provider, subject, principal_id, source)
371
+ VALUES (${provider}, ${subject}, ${id}, 'jit')
372
+ RETURNING *`;
373
+ const [link] = made;
374
+ if (!link)
375
+ throw new IdentityError(`could not link ${provider}:${subject}`);
376
+ return linkOf(link);
377
+ });
378
+ }
379
+
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`;
384
+ return rows.length > 0;
385
+ }
386
+
387
+ async identity(
388
+ provider: string,
389
+ subject: string,
390
+ ): Promise<IdentityLink | undefined> {
391
+ const rows: IdentityRow[] = await this.#sql`
392
+ SELECT * FROM principal_identities WHERE provider = ${provider} AND subject = ${subject}`;
393
+ const [row] = rows;
394
+ return row ? linkOf(row) : undefined;
395
+ }
396
+
397
+ async identitiesOf(principalId: string): Promise<IdentityLink[]> {
398
+ const rows: IdentityRow[] = await this.#sql`
399
+ SELECT * FROM principal_identities WHERE principal_id = ${principalId}
400
+ ORDER BY provider, subject`;
401
+ return rows.map(linkOf);
402
+ }
403
+
404
+ async linksFrom(source: LinkSource): Promise<IdentityLink[]> {
405
+ const rows: IdentityRow[] = await this.#sql`
406
+ SELECT * FROM principal_identities WHERE source = ${source}
407
+ ORDER BY provider, subject`;
408
+ return rows.map(linkOf);
409
+ }
410
+
411
+ async grant(
412
+ principalId: string,
413
+ role: Tier,
414
+ source: RoleSource,
415
+ ): Promise<void> {
416
+ const rows = await this.#sql`
417
+ INSERT INTO principal_roles (principal_id, role, source)
418
+ SELECT id, ${role}, ${source} FROM principals WHERE id = ${principalId}
419
+ ON CONFLICT (principal_id, role) DO UPDATE SET
420
+ source = CASE WHEN principal_roles.source = 'cli' THEN 'cli' ELSE EXCLUDED.source END
421
+ RETURNING role`;
422
+ if (rows.length === 0)
423
+ throw new IdentityError(`there is no principal ${principalId}`);
424
+ }
425
+
426
+ async revoke(
427
+ principalId: string,
428
+ role: Tier,
429
+ source?: RoleSource,
430
+ ): Promise<boolean> {
431
+ const rows =
432
+ source === undefined
433
+ ? await this.#sql`
434
+ DELETE FROM principal_roles WHERE principal_id = ${principalId} AND role = ${role}
435
+ RETURNING role`
436
+ : await this.#sql`
437
+ DELETE FROM principal_roles
438
+ WHERE principal_id = ${principalId} AND role = ${role} AND source = ${source}
439
+ RETURNING role`;
440
+ return rows.length > 0;
441
+ }
442
+
443
+ async rolesOf(principalId: string): Promise<RoleGrant[]> {
444
+ const rows: RoleRow[] = await this.#sql`
445
+ SELECT role, source, granted_at FROM principal_roles WHERE principal_id = ${principalId}
446
+ ORDER BY role`;
447
+ return rows.map((row) => ({
448
+ role: row.role,
449
+ source: row.source,
450
+ grantedAt: row.granted_at,
451
+ }));
452
+ }
453
+
454
+ async holders(role: Tier): Promise<RoleHolder[]> {
455
+ const rows: { principal_id: string; source: RoleSource }[] = await this
456
+ .#sql`
457
+ SELECT principal_id, source FROM principal_roles WHERE role = ${role}
458
+ ORDER BY principal_id`;
459
+ return rows.map((row) => ({
460
+ principalId: row.principal_id,
461
+ source: row.source,
462
+ }));
463
+ }
464
+
465
+ async disable(id: string): Promise<void> {
466
+ await this.#sql`
467
+ UPDATE principals SET disabled_at = COALESCE(disabled_at, now()) WHERE id = ${id}`;
468
+ }
469
+
470
+ async enable(id: string): Promise<void> {
471
+ await this.#sql`UPDATE principals SET disabled_at = NULL WHERE id = ${id}`;
472
+ }
473
+
474
+ async touch(id: string, tier: Tier | null, at = new Date()): Promise<void> {
475
+ await this.#sql`
476
+ UPDATE principals SET last_seen_at = ${at}, last_tier = ${tier} WHERE id = ${id}`;
477
+ }
478
+ }
@@ -0,0 +1,53 @@
1
+ import type { SQL } from "bun";
2
+
3
+ /** The tables of principals, their identities, and their roles. */
4
+ export const PRINCIPAL_TABLES = async (sql: SQL): Promise<void> => {
5
+ await sql`
6
+ CREATE TABLE IF NOT EXISTS principals (
7
+ id text PRIMARY KEY,
8
+ display_name text NOT NULL,
9
+ pronouns text CHECK (pronouns IN ('he', 'she', 'they')),
10
+ locale text,
11
+ time_zone text,
12
+ created_at timestamptz NOT NULL DEFAULT now(),
13
+ disabled_at timestamptz,
14
+ last_seen_at timestamptz,
15
+ last_tier text CHECK (last_tier IN ('member', 'admin', 'owner'))
16
+ )`;
17
+ await sql`
18
+ CREATE TABLE IF NOT EXISTS principal_identities (
19
+ provider text NOT NULL,
20
+ subject text NOT NULL,
21
+ principal_id text NOT NULL REFERENCES principals (id) ON DELETE CASCADE,
22
+ linked_at timestamptz NOT NULL DEFAULT now(),
23
+ source text NOT NULL CHECK (source IN ('config', 'cli', 'jit', 'legacy')),
24
+ PRIMARY KEY (provider, subject)
25
+ )`;
26
+ await sql`
27
+ CREATE INDEX IF NOT EXISTS principal_identities_principal
28
+ ON principal_identities (principal_id)`;
29
+ await sql`
30
+ CREATE TABLE IF NOT EXISTS principal_roles (
31
+ principal_id text NOT NULL REFERENCES principals (id) ON DELETE CASCADE,
32
+ role text NOT NULL CHECK (role IN ('member', 'admin', 'owner')),
33
+ source text NOT NULL CHECK (source IN ('config', 'cli')),
34
+ granted_at timestamptz NOT NULL DEFAULT now(),
35
+ PRIMARY KEY (principal_id, role)
36
+ )`;
37
+ };
38
+
39
+ /**
40
+ * Whether the person of a carried-over principal's 0.8 id may still claim it: its own migration,
41
+ * so a database whose principals table an earlier build made gets the column too.
42
+ */
43
+ export const PRINCIPALS_CLAIMABLE = async (sql: SQL): Promise<void> => {
44
+ await sql`ALTER TABLE principals ADD COLUMN IF NOT EXISTS claimable boolean NOT NULL DEFAULT false`;
45
+ };
46
+
47
+ /** Links a plugin declares: their own source, beside the four the tables were made with. */
48
+ export const PRINCIPAL_PLUGIN_LINKS = async (sql: SQL): Promise<void> => {
49
+ await sql`ALTER TABLE principal_identities DROP CONSTRAINT IF EXISTS principal_identities_source_check`;
50
+ await sql`
51
+ ALTER TABLE principal_identities ADD CONSTRAINT principal_identities_source_check
52
+ CHECK (source IN ('config', 'plugin', 'cli', 'jit', 'legacy'))`;
53
+ };
@@ -0,0 +1,57 @@
1
+ import type { Tier } from "../speakers.ts";
2
+
3
+ /** How often being seen is written for one principal, unless the tier changes. */
4
+ const TOUCH_MS = 5 * 60_000;
5
+
6
+ /** What was last written for one principal. */
7
+ interface Seen {
8
+ at: number;
9
+ /** The tier written, null for a refusal. */
10
+ tier: Tier | null;
11
+ /** When they last came back from a refusal. */
12
+ back?: number;
13
+ /** Until when they are held at no tier for being refused and served by turns. */
14
+ flapping?: number;
15
+ }
16
+
17
+ /**
18
+ * When being seen is written for each principal: at most every `TOUCH_MS` at the same tier, at
19
+ * once when the tier changes, and a refusal whenever the store still holds a tier for them.
20
+ * Someone refused and served by turns, such as by the roles of two Discord servers, is held at
21
+ * no tier for `TOUCH_MS` instead of being written at every message.
22
+ */
23
+ export class SeenThrottle {
24
+ readonly #seen = new Map<string, Seen>();
25
+
26
+ /**
27
+ * Whether to write the principal as seen at `tier` now, recording it as written when so.
28
+ * `stored` says the store holds what a write would leave, as far as the caller knows.
29
+ */
30
+ take(
31
+ principalId: string,
32
+ tier: Tier | null,
33
+ now: number,
34
+ stored: boolean,
35
+ ): boolean {
36
+ const last = this.#seen.get(principalId);
37
+ if (last && last.tier === tier && now - last.at < TOUCH_MS && stored)
38
+ return false;
39
+ if (tier !== null && last?.tier === null && now < (last.flapping ?? 0))
40
+ return false;
41
+ const back = tier !== null && last?.tier === null ? now : last?.back;
42
+ const flapping =
43
+ tier === null && back !== undefined && now - back < TOUCH_MS
44
+ ? now + TOUCH_MS
45
+ : undefined;
46
+ const seen: Seen = { at: now, tier };
47
+ if (back !== undefined) seen.back = back;
48
+ if (flapping !== undefined) seen.flapping = flapping;
49
+ this.#seen.set(principalId, seen);
50
+ return true;
51
+ }
52
+
53
+ /** Forgets what was written for the principal, such as after a write that failed. */
54
+ forget(principalId: string): void {
55
+ this.#seen.delete(principalId);
56
+ }
57
+ }
@@ -0,0 +1,36 @@
1
+ /** Crockford's base 32, the alphabet of a ulid: no I, L, O, or U. */
2
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
3
+
4
+ const PRINCIPAL_ID = /^p_[0-9A-HJKMNP-TV-Z]{26}$/;
5
+
6
+ /** `count` base-32 digits of `value`, most significant first. */
7
+ function digits(value: bigint, count: number): string {
8
+ let out = "";
9
+ let rest = value;
10
+ for (let i = 0; i < count; i++) {
11
+ out = ALPHABET.charAt(Number(rest & 31n)) + out;
12
+ rest >>= 5n;
13
+ }
14
+ return out;
15
+ }
16
+
17
+ /**
18
+ * A ulid: 48 bits of the time in milliseconds, then 80 random bits, as 26 base-32 digits, so ids
19
+ * made later sort after earlier ones.
20
+ */
21
+ function ulid(now = Date.now()): string {
22
+ const random = crypto.getRandomValues(new Uint8Array(10));
23
+ let entropy = 0n;
24
+ for (const byte of random) entropy = (entropy << 8n) | BigInt(byte);
25
+ return digits(BigInt(now), 10) + digits(entropy, 16);
26
+ }
27
+
28
+ /** The id of a principal the host creates: `p_` and a ulid. A principal carried over from 0.8 keeps its old speaker id instead. */
29
+ export function newPrincipalId(): string {
30
+ return `p_${ulid()}`;
31
+ }
32
+
33
+ /** Whether `id` has the form `newPrincipalId` makes. */
34
+ export function isPrincipalId(id: string): boolean {
35
+ return PRINCIPAL_ID.test(id);
36
+ }