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.
- package/CHANGELOG.md +34 -0
- package/docs/migration-0.9.md +3 -1
- package/docs/plugins.md +146 -21
- package/examples/fake-surface.test.ts +137 -0
- package/examples/fake-surface.ts +71 -14
- package/package.json +1 -1
- package/src/cli/checks/identity.ts +73 -5
- package/src/core/agents/agent-claim.ts +12 -0
- package/src/core/agents/team-turns.ts +11 -4
- package/src/core/builtin/agent-server.ts +6 -0
- package/src/core/config/access.ts +23 -3
- package/src/core/config/adapters.ts +11 -0
- package/src/core/config/schema.ts +14 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/define.ts +44 -6
- package/src/core/discord/card-audience.ts +104 -7
- package/src/core/discord/discord-owners.ts +19 -13
- package/src/core/discord/owner-card-types.ts +73 -0
- package/src/core/discord/owner-cards.ts +56 -77
- package/src/core/domain/conversation.ts +7 -0
- package/src/core/domain/ports.ts +4 -1
- package/src/core/holds.ts +60 -0
- package/src/core/i18n/cards.ts +3 -0
- package/src/core/identity/access-policy.ts +65 -2
- package/src/core/identity/caching-principal-store.ts +2 -2
- package/src/core/identity/identity-links.ts +96 -0
- package/src/core/identity/identity-plugin.ts +10 -3
- package/src/core/identity/identity-schema.ts +3 -1
- package/src/core/identity/identity-service.ts +5 -3
- package/src/core/identity/principal-store.ts +59 -142
- package/src/core/identity/principal-tables.ts +10 -2
- package/src/core/identity/principal-types.ts +146 -0
- package/src/core/interactions/prompts.ts +47 -3
- package/src/core/modules/schedules/precheck-tools.ts +115 -20
- package/src/core/routing/conversation-turns.ts +4 -1
- package/src/core/runtime/conversation-sessions.ts +8 -0
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +50 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +252 -204
- package/src/core/runtime/extensions/gate-refusals.ts +133 -0
- package/src/core/runtime/extensions/held-calls.ts +91 -0
- package/src/core/runtime/extensions/hold-lookups.ts +108 -0
- package/src/core/runtime/pi-agent-runtime.ts +7 -3
- package/src/core/runtime/prompt-slot.ts +10 -2
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/services.ts +11 -0
- package/src/core/sessions.ts +7 -0
- package/src/core/testing/surface-contract-support.ts +194 -0
- package/src/core/testing/surface-contract.ts +54 -120
- package/src/core/tool-turn.ts +5 -1
- 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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
:
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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(
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
};
|