pi-roundtable 0.8.0 → 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.
- package/CHANGELOG.md +102 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +606 -78
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +5 -2
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +140 -85
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +78 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +77 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +41 -11
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/host.ts +27 -8
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-router.ts +218 -17
- package/src/core/routing/conversation-turns.ts +38 -8
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +135 -134
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +50 -5
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +8 -0
- package/src/index.ts +47 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { Pronouns } from "../config/config.ts";
|
|
2
|
+
import { ConfigError } from "../domain/errors.ts";
|
|
3
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
4
|
+
import type {
|
|
5
|
+
SpeakerFacts,
|
|
6
|
+
SpeakerMap,
|
|
7
|
+
SpeakerPolicy,
|
|
8
|
+
Tier,
|
|
9
|
+
TierMembers,
|
|
10
|
+
} from "../speakers.ts";
|
|
11
|
+
import {
|
|
12
|
+
type ActorFacts,
|
|
13
|
+
identityOf,
|
|
14
|
+
parseIdentity,
|
|
15
|
+
surfaceOf,
|
|
16
|
+
} from "./actor-facts.ts";
|
|
17
|
+
import { SYSTEM_PRINCIPAL } from "./principal-store.ts";
|
|
18
|
+
|
|
19
|
+
/** One owner the configuration names: whom they are, and the identities that are theirs. */
|
|
20
|
+
export interface AccessOwner {
|
|
21
|
+
name: string;
|
|
22
|
+
pronouns?: Pronouns;
|
|
23
|
+
/** Their principal's id, such as the 0.8 `owner.id`; without it, a new `p_` principal is made at the first boot and found again by the identities after. */
|
|
24
|
+
principal?: string;
|
|
25
|
+
/** `<provider>:<subject>` strings, such as `discord:<user id>` or `token:remote-mcp`. */
|
|
26
|
+
identities: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Who holds a tier besides the owners: identities, roles, or everyone. Roles are written
|
|
31
|
+
* `<surface>:role:<name>`. `everyone: true` admits everyone on every surface, a list only on
|
|
32
|
+
* those surfaces.
|
|
33
|
+
*/
|
|
34
|
+
export interface AccessTier {
|
|
35
|
+
identities?: readonly string[];
|
|
36
|
+
roles?: readonly string[];
|
|
37
|
+
everyone?: boolean | readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Who the host serves, and at which tier. */
|
|
41
|
+
export interface AccessRules {
|
|
42
|
+
/** 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
|
+
owners: readonly AccessOwner[];
|
|
44
|
+
admins?: AccessTier;
|
|
45
|
+
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";
|
|
48
|
+
/** How many days a principal whose tier came only from the rules on contact may go unseen before their background turns are refused. */
|
|
49
|
+
backgroundStaleDays: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const holds = (
|
|
53
|
+
tier: AccessTier | undefined,
|
|
54
|
+
facts: ActorFacts,
|
|
55
|
+
surface: string,
|
|
56
|
+
): boolean => {
|
|
57
|
+
if (!tier) return false;
|
|
58
|
+
const { everyone } = tier;
|
|
59
|
+
if (everyone === true) return true;
|
|
60
|
+
if (Array.isArray(everyone) && everyone.includes(surface)) return true;
|
|
61
|
+
if (tier.identities?.includes(identityOf(facts))) return true;
|
|
62
|
+
return (facts.roles ?? []).some((role) => tier.roles?.includes(role));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The tier the rules give these facts on this contact: admin, member, or none. Never owner: an
|
|
67
|
+
* owner is one by the configuration's owners or a lasting role, not by what a surface reports.
|
|
68
|
+
*/
|
|
69
|
+
export function factsTier(
|
|
70
|
+
rules: Pick<AccessRules, "admins" | "members">,
|
|
71
|
+
facts: ActorFacts,
|
|
72
|
+
conversation?: ChannelKey,
|
|
73
|
+
): Exclude<Tier, "owner"> | undefined {
|
|
74
|
+
const surface = surfaceOf(facts, conversation);
|
|
75
|
+
if (holds(rules.admins, facts, surface)) return "admin";
|
|
76
|
+
if (holds(rules.members, facts, surface)) return "member";
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The configured owner whose identities include these facts', if any. */
|
|
81
|
+
export function ownerOfFacts(
|
|
82
|
+
rules: Pick<AccessRules, "owners">,
|
|
83
|
+
facts: ActorFacts,
|
|
84
|
+
): AccessOwner | undefined {
|
|
85
|
+
const identity = identityOf(facts);
|
|
86
|
+
return rules.owners.find((owner) => owner.identities.includes(identity));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ROLE = /^[^:]+:role:.+$/;
|
|
90
|
+
|
|
91
|
+
function checkTier(tier: AccessTier | undefined, path: string): void {
|
|
92
|
+
if (!tier) return;
|
|
93
|
+
tier.identities?.forEach((identity, i) => {
|
|
94
|
+
if (!parseIdentity(identity))
|
|
95
|
+
throw new ConfigError(
|
|
96
|
+
`config ${path}.identities[${i}]: expected <provider>:<subject>, got ${JSON.stringify(identity)}. Write it like discord:<user id>; roundtable principal list prints them.`,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
tier.roles?.forEach((role, i) => {
|
|
100
|
+
if (!ROLE.test(role))
|
|
101
|
+
throw new ConfigError(
|
|
102
|
+
`config ${path}.roles[${i}]: expected <surface>:role:<name>, got ${JSON.stringify(role)}. Write it like discord:role:<role id> or web:role:<role name>.`,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The rules, checked: identities and roles well formed, no identity or principal under two owners, no owner as the system principal. */
|
|
108
|
+
export function checkAccessRules(rules: AccessRules): AccessRules {
|
|
109
|
+
const seenIdentity = new Map<string, number>();
|
|
110
|
+
const seenPrincipal = new Map<string, number>();
|
|
111
|
+
rules.owners.forEach((owner, n) => {
|
|
112
|
+
const path = `access.owners[${n}]`;
|
|
113
|
+
if (owner.principal === SYSTEM_PRINCIPAL)
|
|
114
|
+
throw new ConfigError(
|
|
115
|
+
`config ${path}.principal: "${SYSTEM_PRINCIPAL}" is the host's own principal. Give the owner's own id, or leave principal out.`,
|
|
116
|
+
);
|
|
117
|
+
if (owner.principal === undefined && owner.identities.length === 0)
|
|
118
|
+
throw new ConfigError(
|
|
119
|
+
`config ${path}: give the owner a principal id or at least one identity, so each boot finds the same principal.`,
|
|
120
|
+
);
|
|
121
|
+
if (owner.principal !== undefined) {
|
|
122
|
+
const other = seenPrincipal.get(owner.principal);
|
|
123
|
+
if (other !== undefined)
|
|
124
|
+
throw new ConfigError(
|
|
125
|
+
`config ${path}.principal: access.owners[${other}] has the same principal ${owner.principal}. Merge the two owners into one.`,
|
|
126
|
+
);
|
|
127
|
+
seenPrincipal.set(owner.principal, n);
|
|
128
|
+
}
|
|
129
|
+
owner.identities.forEach((identity, i) => {
|
|
130
|
+
if (!parseIdentity(identity))
|
|
131
|
+
throw new ConfigError(
|
|
132
|
+
`config ${path}.identities[${i}]: expected <provider>:<subject>, got ${JSON.stringify(identity)}. Write it like discord:<user id>; roundtable principal list prints them.`,
|
|
133
|
+
);
|
|
134
|
+
const other = seenIdentity.get(identity);
|
|
135
|
+
if (other !== undefined)
|
|
136
|
+
throw new ConfigError(
|
|
137
|
+
`config ${path}.identities[${i}]: ${identity} is also listed under access.owners[${other}]. An identity is one person's; keep it under one owner.`,
|
|
138
|
+
);
|
|
139
|
+
seenIdentity.set(identity, n);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
checkTier(rules.admins, "access.admins");
|
|
143
|
+
checkTier(rules.members, "access.members");
|
|
144
|
+
if (
|
|
145
|
+
!(
|
|
146
|
+
Number.isInteger(rules.backgroundStaleDays) &&
|
|
147
|
+
rules.backgroundStaleDays > 0
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
throw new ConfigError(
|
|
151
|
+
`config access.backgroundStaleDays: expected a positive whole number of days, got ${JSON.stringify(rules.backgroundStaleDays)}.`,
|
|
152
|
+
);
|
|
153
|
+
return rules;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tierOfMembers(
|
|
157
|
+
members: TierMembers | undefined,
|
|
158
|
+
provider: string,
|
|
159
|
+
): AccessTier | undefined {
|
|
160
|
+
if (!members) return undefined;
|
|
161
|
+
return {
|
|
162
|
+
...(members.users
|
|
163
|
+
? { identities: members.users.map((id) => `${provider}:${id}`) }
|
|
164
|
+
: {}),
|
|
165
|
+
...(members.roles
|
|
166
|
+
? { roles: members.roles.map((role) => `${provider}:role:${role}`) }
|
|
167
|
+
: {}),
|
|
168
|
+
...(members.everyone === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: { everyone: members.everyone ? [provider] : false }),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The rules a 0.8 speaker map means on one surface: its owners by id, its users as identities of
|
|
176
|
+
* that surface, its roles as that surface's roles, and `everyone` as everyone there.
|
|
177
|
+
*/
|
|
178
|
+
export function rulesOfSpeakerMap(
|
|
179
|
+
map: SpeakerMap,
|
|
180
|
+
provider = "discord",
|
|
181
|
+
): Pick<AccessRules, "owners" | "admins" | "members"> {
|
|
182
|
+
const admins = tierOfMembers(map.admins, provider);
|
|
183
|
+
const members = tierOfMembers(map.members, provider);
|
|
184
|
+
return {
|
|
185
|
+
owners: map.owners.map((id) => ({
|
|
186
|
+
name: id,
|
|
187
|
+
principal: id,
|
|
188
|
+
identities: [`${provider}:${id}`],
|
|
189
|
+
})),
|
|
190
|
+
...(admins ? { admins } : {}),
|
|
191
|
+
...(members ? { members } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The 0.8 policy over these rules on one surface, for the code that still resolves a speaker
|
|
197
|
+
* from a surface's facts by itself: an author is an owner when one of the owners lists their
|
|
198
|
+
* identity there, and otherwise holds the tier the rules give them. Each speaker's principal is
|
|
199
|
+
* their own id, as for a person carried over from 0.8; the identity service knows the others.
|
|
200
|
+
*/
|
|
201
|
+
export function speakerPolicyOf(
|
|
202
|
+
rules: Pick<AccessRules, "owners" | "admins" | "members">,
|
|
203
|
+
provider = "discord",
|
|
204
|
+
): SpeakerPolicy {
|
|
205
|
+
return {
|
|
206
|
+
resolve(author: SpeakerFacts) {
|
|
207
|
+
const facts: ActorFacts = {
|
|
208
|
+
provider,
|
|
209
|
+
subject: author.id,
|
|
210
|
+
name: author.name,
|
|
211
|
+
surface: provider,
|
|
212
|
+
roles: (author.roleIds ?? []).map((role) => `${provider}:role:${role}`),
|
|
213
|
+
};
|
|
214
|
+
const tier: Tier | undefined = ownerOfFacts(rules, facts)
|
|
215
|
+
? "owner"
|
|
216
|
+
: factsTier(rules, facts);
|
|
217
|
+
return (
|
|
218
|
+
tier && {
|
|
219
|
+
id: author.id,
|
|
220
|
+
name: author.name,
|
|
221
|
+
tier,
|
|
222
|
+
principalId: author.id,
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { parseChannelKey } from "../contract/surface.ts";
|
|
2
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
3
|
+
import type { IdentityRef } from "./principal-store.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a surface reports about the person behind a message or a connection; the identity
|
|
7
|
+
* service decides who they are and the tier they hold.
|
|
8
|
+
*/
|
|
9
|
+
export interface ActorFacts {
|
|
10
|
+
/** The identity provider: `discord`, `oidc:<base64url issuer>`, `token`, … */
|
|
11
|
+
provider: string;
|
|
12
|
+
/** The person's id at the provider. */
|
|
13
|
+
subject: string;
|
|
14
|
+
/** The name to address them by on this contact. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** The roles they hold, each prefixed with its surface: `discord:role:<id>`, `web:role:<name>`. */
|
|
17
|
+
roles?: readonly string[];
|
|
18
|
+
/**
|
|
19
|
+
* The surface they reached the host through, as `everyone` lists name it, such as `discord` or
|
|
20
|
+
* `web`; default the conversation's surface, then the provider up to its first colon.
|
|
21
|
+
*/
|
|
22
|
+
surface?: string;
|
|
23
|
+
/** Where on the surface, such as a guild; informational. */
|
|
24
|
+
space?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The speaker id 0.8 gave this person, which a principal carried over from 0.8 has as its id:
|
|
27
|
+
* the user id on Discord, `oidc:<issuer>:<subject>` on the web. At first contact it claims that
|
|
28
|
+
* principal. It is also the speaker's `id`, the id the surface knows them by.
|
|
29
|
+
*/
|
|
30
|
+
legacyId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An identity as configuration and the CLI write it: `<provider>:<subject>`. */
|
|
34
|
+
export function identityOf(ref: IdentityRef): string {
|
|
35
|
+
return `${ref.provider}:${ref.subject}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An identity string read back: the provider is the part before the first colon, and for `oidc`
|
|
40
|
+
* the issuer after it too, so `oidc:<issuer>:<subject>` is the 0.8 web speaker id. Undefined
|
|
41
|
+
* when a part is missing.
|
|
42
|
+
*/
|
|
43
|
+
export function parseIdentity(identity: string): IdentityRef | undefined {
|
|
44
|
+
const first = identity.indexOf(":");
|
|
45
|
+
if (first <= 0) return undefined;
|
|
46
|
+
let cut = first;
|
|
47
|
+
if (identity.slice(0, first) === "oidc") {
|
|
48
|
+
cut = identity.indexOf(":", first + 1);
|
|
49
|
+
if (cut <= first + 1) return undefined;
|
|
50
|
+
}
|
|
51
|
+
const subject = identity.slice(cut + 1);
|
|
52
|
+
if (subject === "") return undefined;
|
|
53
|
+
return { provider: identity.slice(0, cut), subject };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The surface the facts came through: their own, the conversation's, or the provider's first part. */
|
|
57
|
+
export function surfaceOf(
|
|
58
|
+
facts: ActorFacts,
|
|
59
|
+
conversation?: ChannelKey,
|
|
60
|
+
): string {
|
|
61
|
+
if (facts.surface) return facts.surface;
|
|
62
|
+
if (conversation) return parseChannelKey(conversation).surface;
|
|
63
|
+
const colon = facts.provider.indexOf(":");
|
|
64
|
+
return colon < 0 ? facts.provider : facts.provider.slice(0, colon);
|
|
65
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Tier } from "../speakers.ts";
|
|
2
|
+
import type {
|
|
3
|
+
IdentityRef,
|
|
4
|
+
LinkSource,
|
|
5
|
+
NewPrincipal,
|
|
6
|
+
PrincipalStore,
|
|
7
|
+
Pronouns,
|
|
8
|
+
RoleSource,
|
|
9
|
+
} from "./principal-store.ts";
|
|
10
|
+
|
|
11
|
+
/** How long a read of the store is reused; a change another process makes is seen after it. */
|
|
12
|
+
export const CACHE_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The store with its reads reused for `CACHE_MS`; any write through it drops them all, so this
|
|
16
|
+
* process sees its own changes at once. Recording a principal as seen drops only theirs.
|
|
17
|
+
*/
|
|
18
|
+
export class CachingPrincipalStore implements PrincipalStore {
|
|
19
|
+
readonly #store: PrincipalStore;
|
|
20
|
+
readonly #now: () => number;
|
|
21
|
+
readonly #reads = new Map<string, { at: number; value: Promise<unknown> }>();
|
|
22
|
+
|
|
23
|
+
constructor(store: PrincipalStore, now: () => number) {
|
|
24
|
+
this.#store = store;
|
|
25
|
+
this.#now = now;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#cached<T>(key: string, read: () => Promise<T>): Promise<T> {
|
|
29
|
+
const now = this.#now();
|
|
30
|
+
const hit = this.#reads.get(key);
|
|
31
|
+
if (hit && now - hit.at < CACHE_MS) return hit.value as Promise<T>;
|
|
32
|
+
const value = read();
|
|
33
|
+
this.#reads.set(key, { at: now, value });
|
|
34
|
+
value.catch(() => this.#reads.delete(key));
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async #write<T>(write: Promise<T>): Promise<T> {
|
|
39
|
+
try {
|
|
40
|
+
return await write;
|
|
41
|
+
} finally {
|
|
42
|
+
this.#reads.clear();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get(id: string) {
|
|
47
|
+
return this.#cached(`p\0${id}`, () => this.#store.get(id));
|
|
48
|
+
}
|
|
49
|
+
list() {
|
|
50
|
+
return this.#store.list();
|
|
51
|
+
}
|
|
52
|
+
identity(provider: string, subject: string) {
|
|
53
|
+
return this.#cached(`i\0${provider}\0${subject}`, () =>
|
|
54
|
+
this.#store.identity(provider, subject),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
identitiesOf(principalId: string) {
|
|
58
|
+
return this.#store.identitiesOf(principalId);
|
|
59
|
+
}
|
|
60
|
+
rolesOf(principalId: string) {
|
|
61
|
+
return this.#cached(`r\0${principalId}`, () =>
|
|
62
|
+
this.#store.rolesOf(principalId),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
holders(role: Tier) {
|
|
66
|
+
return this.#store.holders(role);
|
|
67
|
+
}
|
|
68
|
+
linksFrom(source: LinkSource) {
|
|
69
|
+
return this.#store.linksFrom(source);
|
|
70
|
+
}
|
|
71
|
+
create(input: NewPrincipal) {
|
|
72
|
+
return this.#write(this.#store.create(input));
|
|
73
|
+
}
|
|
74
|
+
update(
|
|
75
|
+
id: string,
|
|
76
|
+
change: { displayName?: string; pronouns?: Pronouns | null },
|
|
77
|
+
) {
|
|
78
|
+
return this.#write(this.#store.update(id, change));
|
|
79
|
+
}
|
|
80
|
+
link(principalId: string, identity: IdentityRef, source: LinkSource) {
|
|
81
|
+
return this.#write(this.#store.link(principalId, identity, source));
|
|
82
|
+
}
|
|
83
|
+
claim(principalId: string, identity: IdentityRef, displayName?: string) {
|
|
84
|
+
return this.#write(this.#store.claim(principalId, identity, displayName));
|
|
85
|
+
}
|
|
86
|
+
admit(identity: IdentityRef, displayName: string, id?: string) {
|
|
87
|
+
return this.#write(this.#store.admit(identity, displayName, id));
|
|
88
|
+
}
|
|
89
|
+
unlink(provider: string, subject: string) {
|
|
90
|
+
return this.#write(this.#store.unlink(provider, subject));
|
|
91
|
+
}
|
|
92
|
+
grant(principalId: string, role: Tier, source: RoleSource) {
|
|
93
|
+
return this.#write(this.#store.grant(principalId, role, source));
|
|
94
|
+
}
|
|
95
|
+
revoke(principalId: string, role: Tier, source?: RoleSource) {
|
|
96
|
+
return this.#write(this.#store.revoke(principalId, role, source));
|
|
97
|
+
}
|
|
98
|
+
disable(id: string) {
|
|
99
|
+
return this.#write(this.#store.disable(id));
|
|
100
|
+
}
|
|
101
|
+
enable(id: string) {
|
|
102
|
+
return this.#write(this.#store.enable(id));
|
|
103
|
+
}
|
|
104
|
+
async touch(id: string, tier: Tier | null, at?: Date) {
|
|
105
|
+
try {
|
|
106
|
+
await this.#store.touch(id, tier, at);
|
|
107
|
+
} finally {
|
|
108
|
+
this.#reads.delete(`p\0${id}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { ConfigError } from "../domain/errors.ts";
|
|
2
|
+
import type { Logger } from "../log.ts";
|
|
3
|
+
import type { AccessOwner } from "./access-policy.ts";
|
|
4
|
+
import { identityOf } from "./actor-facts.ts";
|
|
5
|
+
import type { DeclaredIdentity } from "./plugin-identities.ts";
|
|
6
|
+
import type {
|
|
7
|
+
IdentityLink,
|
|
8
|
+
IdentityRef,
|
|
9
|
+
PrincipalStore,
|
|
10
|
+
} from "./principal-store.ts";
|
|
11
|
+
|
|
12
|
+
/** A configured owner, where the configuration lists them, and what the boot knows of the plugins. */
|
|
13
|
+
export interface ConfiguredOwner {
|
|
14
|
+
owner: AccessOwner;
|
|
15
|
+
/** The owner's index in `access.owners`. */
|
|
16
|
+
index: number;
|
|
17
|
+
/** The owner's identities, parsed, in the configuration's order. */
|
|
18
|
+
refs: readonly IdentityRef[];
|
|
19
|
+
/** Each identity the plugins declare, with the plugin and the principal it names. */
|
|
20
|
+
declaredBy: ReadonlyMap<string, DeclaredIdentity>;
|
|
21
|
+
/** The primary owner's principal, which a plugin naming none binds to; undefined while syncing the primary owner. */
|
|
22
|
+
primary: string | undefined;
|
|
23
|
+
logger: Logger;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A configured owner's identity that stops the start, and how to fix it. */
|
|
27
|
+
export interface OwnerConflict {
|
|
28
|
+
problem: string;
|
|
29
|
+
fix: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a configured owner may list an identity a plugin declares. The principal the plugin binds
|
|
34
|
+
* it to on this boot, its `principal` or else the primary owner, must be this owner, whatever it
|
|
35
|
+
* was bound to before: the boot moves it there. A plugin's credential never tells who the owner is,
|
|
36
|
+
* so an owner nothing else tells conflicts unless they are the primary owner the plugin defaults to
|
|
37
|
+
* and the credential is not a plugin's link to someone already. `primary` undefined with `first`
|
|
38
|
+
* false is a primary owner the start makes.
|
|
39
|
+
*/
|
|
40
|
+
export function declaredConflict({
|
|
41
|
+
index: n,
|
|
42
|
+
i,
|
|
43
|
+
identity,
|
|
44
|
+
id,
|
|
45
|
+
link,
|
|
46
|
+
declared: { plugin, principal },
|
|
47
|
+
primary,
|
|
48
|
+
first,
|
|
49
|
+
}: {
|
|
50
|
+
index: number;
|
|
51
|
+
i: number;
|
|
52
|
+
identity: string;
|
|
53
|
+
/** The principal this owner is, as everything but a plugin's credential tells; undefined for one the boot makes. */
|
|
54
|
+
id: string | undefined;
|
|
55
|
+
link: IdentityLink | undefined;
|
|
56
|
+
declared: DeclaredIdentity;
|
|
57
|
+
primary: string | undefined;
|
|
58
|
+
first: boolean;
|
|
59
|
+
}): OwnerConflict | undefined {
|
|
60
|
+
const at = `access.owners[${n}].identities[${i}]: ${identity} is an identity plugin ${plugin} declares`;
|
|
61
|
+
const remove = `Remove it from access.owners[${n}].identities`;
|
|
62
|
+
const untold: OwnerConflict = {
|
|
63
|
+
problem: "a plugin's credential does not tell who this owner is",
|
|
64
|
+
fix: `${remove}, or give this owner its principal and bind plugin ${plugin} to it`,
|
|
65
|
+
};
|
|
66
|
+
if (principal === undefined && first) {
|
|
67
|
+
// Bound to this owner, the primary owner: whoever they are, unless only that link would tell.
|
|
68
|
+
if (id !== undefined || link?.source !== "plugin") return undefined;
|
|
69
|
+
return {
|
|
70
|
+
...untold,
|
|
71
|
+
problem: `${at}, bound to principal ${link.principalId}, and ${untold.problem}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const target = principal ?? primary;
|
|
75
|
+
if (target !== undefined && target === id) return undefined;
|
|
76
|
+
const bound = `${at}, bound to ${target === undefined ? "the principal the start makes for access.owners[0]" : `principal ${target}`}`;
|
|
77
|
+
return id === undefined
|
|
78
|
+
? { ...untold, problem: `${bound}, and ${untold.problem}` }
|
|
79
|
+
: {
|
|
80
|
+
problem: `${bound}, not to this owner's ${id}`,
|
|
81
|
+
fix: `${remove}, or bind plugin ${plugin} to ${id} in its options`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The principal a configured owner is: their `principal`, else the principal an identity of theirs
|
|
87
|
+
* is linked to; undefined for someone the boot makes. A plugin's credential is not the person's own
|
|
88
|
+
* identity, so a plugin's link never tells who the owner is, and one no plugin declares any more is
|
|
89
|
+
* unlinked first, as the boot would anyway. An identity linked to another principal stops the
|
|
90
|
+
* boot, and so does an identity a plugin declares that `declaredConflict` refuses. One a plugin
|
|
91
|
+
* declares and binds to this owner on this boot, still the plugin's link to someone else, is moved
|
|
92
|
+
* now, before the owner's identities are linked.
|
|
93
|
+
*/
|
|
94
|
+
export async function configuredOwnerId(
|
|
95
|
+
store: PrincipalStore,
|
|
96
|
+
{ owner, index: n, refs, declaredBy, primary, logger }: ConfiguredOwner,
|
|
97
|
+
): Promise<string | undefined> {
|
|
98
|
+
const links = await Promise.all(
|
|
99
|
+
refs.map(async (ref) => {
|
|
100
|
+
const link = await store.identity(ref.provider, ref.subject);
|
|
101
|
+
if (link?.source !== "plugin" || declaredBy.has(identityOf(link)))
|
|
102
|
+
return link;
|
|
103
|
+
// No plugin declares it any more, so this boot unlinks it anyway: before it tells anyone anything.
|
|
104
|
+
await store.unlink(link.provider, link.subject);
|
|
105
|
+
logger.info(
|
|
106
|
+
`${identityOf(link)} is no longer an identity a plugin declares; it was unlinked from principal ${link.principalId}`,
|
|
107
|
+
);
|
|
108
|
+
return undefined;
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
let id = owner.principal;
|
|
112
|
+
links.forEach((link, i) => {
|
|
113
|
+
if (!link || link.source === "plugin") return;
|
|
114
|
+
id ??= link.principalId;
|
|
115
|
+
if (link.principalId !== id)
|
|
116
|
+
throw new ConfigError(
|
|
117
|
+
`config access.owners[${n}].identities[${i}]: ${owner.identities[i]} is linked to principal ${link.principalId}, not to this owner's ${id}. Unlink it with roundtable principal unlink ${owner.identities[i]}, or fix the configuration.`,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
for (const [i, ref] of refs.entries()) {
|
|
121
|
+
const declared = declaredBy.get(identityOf(ref));
|
|
122
|
+
if (!declared) continue;
|
|
123
|
+
const link = links[i];
|
|
124
|
+
const conflict = declaredConflict({
|
|
125
|
+
index: n,
|
|
126
|
+
i,
|
|
127
|
+
identity: owner.identities[i] ?? identityOf(ref),
|
|
128
|
+
id,
|
|
129
|
+
link,
|
|
130
|
+
declared,
|
|
131
|
+
primary,
|
|
132
|
+
first: n === 0,
|
|
133
|
+
});
|
|
134
|
+
// The CLI refuses to unlink a plugin's identity: the plugin's options move it.
|
|
135
|
+
if (conflict)
|
|
136
|
+
throw new ConfigError(`config ${conflict.problem}. ${conflict.fix}.`);
|
|
137
|
+
if (link?.source !== "plugin" || link.principalId === id) continue;
|
|
138
|
+
// The plugin binds it to this owner on this boot, and a link refuses an identity linked elsewhere.
|
|
139
|
+
await store.unlink(link.provider, link.subject);
|
|
140
|
+
logger.info(
|
|
141
|
+
`${identityOf(link)} of plugin ${declared.plugin} moved from principal ${link.principalId} to ${id}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return id;
|
|
145
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
2
|
+
import type { Speaker } from "../speakers.ts";
|
|
3
|
+
import type { ActorFacts } from "./actor-facts.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A person a surface reports, assessed before any claim takes their message: who they would be,
|
|
7
|
+
* with nothing written yet.
|
|
8
|
+
*/
|
|
9
|
+
export interface Contact {
|
|
10
|
+
/** The speaker they would be: their principal's, or at a first contact the principal they would claim or be admitted as. */
|
|
11
|
+
readonly speaker: Speaker;
|
|
12
|
+
/**
|
|
13
|
+
* Records the contact once a claim takes their message: claims their 0.8 principal or admits
|
|
14
|
+
* them first, then records them as seen. The speaker they are now, whose principal differs from
|
|
15
|
+
* the assessed one only when another process linked them meanwhile; undefined when they are no
|
|
16
|
+
* one now.
|
|
17
|
+
*/
|
|
18
|
+
take(): Promise<Speaker | undefined>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Assesses the people of messages before a claim takes them, so nobody's message that no claim serves writes anything. Core-internal: the router's. */
|
|
22
|
+
export interface ContactAssessor {
|
|
23
|
+
assess(
|
|
24
|
+
facts: ActorFacts,
|
|
25
|
+
scope?: { conversation?: ChannelKey },
|
|
26
|
+
): Promise<Contact | undefined>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { RoundtablePlugin } from "../plugin.ts";
|
|
2
|
+
import { IDENTITY } from "../services.ts";
|
|
3
|
+
import type { AccessRules } from "./access-policy.ts";
|
|
4
|
+
import { type BackfillSummary, identityMigrations } from "./identity-schema.ts";
|
|
5
|
+
import { PgIdentityService } from "./identity-service.ts";
|
|
6
|
+
import { identityView } from "./identity-view.ts";
|
|
7
|
+
import { declaredIdentities } from "./plugin-identities.ts";
|
|
8
|
+
import { PgPrincipalStore } from "./principal-store.ts";
|
|
9
|
+
|
|
10
|
+
/** The name of the identity plugin, as the migration ledger names it. */
|
|
11
|
+
export const IDENTITY_PLUGIN = "identity";
|
|
12
|
+
|
|
13
|
+
export interface IdentityOptions {
|
|
14
|
+
/** Who the host serves; the backfill makes the owners with a principal id first, by their names, and caps what a carried-over author of schedules is seen at by the tiers. */
|
|
15
|
+
rules: AccessRules;
|
|
16
|
+
/** The host's other plugins, whose declared `identities` setup links; checked when this plugin is made. */
|
|
17
|
+
plugins?: readonly RoundtablePlugin[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One line for the log: what the boot's backfill made, and from where. */
|
|
21
|
+
export function backfillLine(summary: BackfillSummary): string {
|
|
22
|
+
const sources = Object.entries(summary.sources)
|
|
23
|
+
.map(([source, count]) => `${source} ${count}`)
|
|
24
|
+
.join(", ");
|
|
25
|
+
return `principal backfill: ${summary.created} created; ids found: ${sources}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The principals, their identity links, and their roles: the tables, the backfill that makes a
|
|
30
|
+
* principal of every person 0.8 stored, and the identity service over them provided as
|
|
31
|
+
* `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
|
+
*/
|
|
34
|
+
export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
|
|
35
|
+
const { rules } = options;
|
|
36
|
+
const identities = declaredIdentities(options.plugins ?? []);
|
|
37
|
+
let summary: BackfillSummary | undefined;
|
|
38
|
+
return {
|
|
39
|
+
name: IDENTITY_PLUGIN,
|
|
40
|
+
migrations: identityMigrations(rules, (made) => {
|
|
41
|
+
summary = made;
|
|
42
|
+
}),
|
|
43
|
+
provides: [IDENTITY],
|
|
44
|
+
setup: async ({ database, services, logger }) => {
|
|
45
|
+
if (summary) logger.info(backfillLine(summary));
|
|
46
|
+
const identity = new PgIdentityService(
|
|
47
|
+
await PgPrincipalStore.attach(database()),
|
|
48
|
+
rules,
|
|
49
|
+
{ logger, identities },
|
|
50
|
+
);
|
|
51
|
+
await identity.syncConfig();
|
|
52
|
+
services.provide(IDENTITY, identityView(identity));
|
|
53
|
+
return {};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|