@inixiative/signet 0.11.0 → 0.12.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/README.md +26 -10
- package/package.json +5 -3
- package/src/archive.ts +15 -21
- package/src/client.ts +2 -2
- package/src/contracts.ts +26 -125
- package/src/events.ts +28 -66
- package/src/github.ts +13 -27
- package/src/grants.ts +127 -0
- package/src/installationSocket.ts +5 -25
- package/src/lens.ts +54 -0
- package/src/operations.ts +144 -0
- package/src/threads.ts +16 -61
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A **Signet** is Kingdom's only grant for using an integration. It names the reso
|
|
|
6
6
|
|
|
7
7
|
This package is the one implementation of that protocol, shared by Kingdom (which verifies) and Archive, Foundry and Oracle (which present):
|
|
8
8
|
|
|
9
|
-
- `contracts`: the wire schemas: keys,
|
|
9
|
+
- `contracts`: the wire schemas: keys, grants (operation picks, lens layers, terms), proposals, pairing requests, delivered credentials, `describe` and `execute`, and the conversation frames.
|
|
10
10
|
- `proof`: create and verify DPoP proofs (ES256, nonce-bound, access-token-bound).
|
|
11
11
|
- `files`: private 0600 JSON files for keys and credentials.
|
|
12
12
|
- `client`:
|
|
@@ -26,7 +26,7 @@ const paired = await pairInstallation({
|
|
|
26
26
|
terms: {
|
|
27
27
|
name: 'Laptop Archive', lifecycle: 'ongoing', expiresAt: null, maxRequests: null, maxConcurrent: 4,
|
|
28
28
|
owner: { ownerModel: 'Organization', organization: 'inixiative' },
|
|
29
|
-
resources: [{ resourceKind: 'archiveLibrary', operations: ['sessions.write'] }],
|
|
29
|
+
resources: [{ resourceKind: 'archiveLibrary', operations: { picks: ['sessions.write'] } }],
|
|
30
30
|
},
|
|
31
31
|
onReview: ({ review }) => console.log(`Approve in Kingdom: ${review}`),
|
|
32
32
|
confirmOwner: async ({ ownerName }) => confirm(`Pair with ${ownerName}?`),
|
|
@@ -50,7 +50,7 @@ import { awaitSignet, enrollSignet, generateSignetKey, requestSignet, saveCollec
|
|
|
50
50
|
await writePrivateJson(keyFile, generateSignetKey());
|
|
51
51
|
const { requestId, reviewCode } = await requestSignet(kingdom, keyFile, apiToken, {
|
|
52
52
|
name: 'Research agent', integrationId, lifecycle: 'ongoing', expiresAt: null, maxRequests: null, maxConcurrent: 1,
|
|
53
|
-
resources: [{
|
|
53
|
+
resources: [{ resourceModel: 'IntegrationResource', integrationResourceId, operations: { picks: ['documents.read'] } }],
|
|
54
54
|
});
|
|
55
55
|
const collected = await awaitSignet(kingdom, keyFile, requestId); // or, for a granted Signet:
|
|
56
56
|
// const collected = await enrollSignet(kingdom, keyFile, apiToken, { signetId, expectedRevision, name: 'Research agent' });
|
|
@@ -59,21 +59,37 @@ const signet = await SignetClient.fromFile(credentialFile);
|
|
|
59
59
|
await signet.describe();
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Grants
|
|
63
|
+
|
|
64
|
+
A Signet revision grants one row per resource (`grantResourceSchema`), false polymorphic on `resourceModel`:
|
|
65
|
+
|
|
66
|
+
- **`IntegrationResource`**: `full`, or `operations` (the resolved leaves; absent when `full`), `lens` (json-rules layers) and `terms` (per kind). `full` reaches everything the resource offers, now and as its catalog grows, capped live by the integration's baseline. It is a different axis from `Integration.full` / `Token.full`, which let a delegate act with its owner's whole authority.
|
|
67
|
+
- **`Project`**: a Kingdom project the Signet may be used in. Nothing else: no `full`, operations, lens or terms.
|
|
68
|
+
|
|
69
|
+
**Operation picks.** A provider's catalog is a flat list of dotted leaf keys per resource kind (`commits.read`, `events.pull_request.review_requested`). A party asks with `operationPickSchema`: `{ full: true }` or `{ picks }`, each pick a leaf or a prefix ending `.*` (`commits.*`, `events.pull_request.*`). `resolveOperationPicks(leaves, pick)` resolves picks to leaves once, when the grant is saved (a pick that reaches nothing throws `OperationPickError`, code `unknown_operation_pick`), so a later catalog addition reaches only `full` rows. `operationTree(leaves)` derives the nested tree a grant UI offers, every node with the pick that takes it. `narrowsOperations(inner, outer)`: a full `outer` contains everything, and a full `inner` under an `outer` that is not full is never a narrowing. `permitsOperation(scope, operation)` answers one operation; `scopeRefusal(scope, catalog, refusal)` runs a per-operation refusal over every catalog operation a row reaches (all of them when full, so re-run it when the catalog grows).
|
|
70
|
+
|
|
71
|
+
**Lens.** A grant's lens is a chain of json-rules layers over the integration's base lens for the resource's model, which Kingdom's catalog holds as code under `provider:kind`. A root `where` filters rows; picks and omits trim the shape; no lens means the base. A party writes one layer (`lensLayerSchema`, a json-rules narrowing without its parent); Kingdom stores it as a `StoredLens` record (`storedLensLayerSchema`) under the base, an approver's layer under the request's, so every layer ANDs and an approver can only narrow. This package depends on json-rules for types only, and checks only a layer's envelope: Kingdom validates (`validateNarrowing`), composes (`composeLens`) and applies (`projectRows`) the layers. The base's id is `baseLensId(provider, kind)`. The refusals below read a `GrantLens`: the grant's own layers plus `projectLens` of the composed chain (`projectionFilters`, `projectionShows`). A `where` runs over a row: `repositoryCallRow(input)` (`{ sha }`) for a repository call, `threadRow(threadId)` (`{ id }`) for a thread.
|
|
72
|
+
|
|
73
|
+
**Terms** are neither operations nor lens: how a holder takes part (`grantTermsSchema`, strict per kind; `grantTermsFitKind(kind, terms)`). Today only `threads` has terms.
|
|
74
|
+
|
|
75
|
+
What a party asks for (`signetResourceSchema` by id, `requestedResourceSchema` by id or by kind) carries picks, its own lens layer and terms (a request has no kind, so Kingdom checks `grantTermsFitKind` once it resolves the resource); what a holder reads (`signetGrantSchema`) is the stored row plus the resource's `kind`.
|
|
76
|
+
|
|
62
77
|
## Declared operations
|
|
63
78
|
|
|
64
|
-
An integration whose operations Kingdom executes generically declares them here, one module per resource kind: `threads.ts` for a Foundry's `threads` resource, `archive.ts` for an Archive's `archiveLibrary`, `github.ts` for a GitHub `repository` (reads by object id
|
|
79
|
+
An integration whose operations Kingdom executes generically declares them here, one module per resource kind: `threads.ts` for a Foundry's `threads` resource, `archive.ts` for an Archive's `archiveLibrary`, `github.ts` for a GitHub `repository` (reads by object id). Each declares its operation enum, display names and the input each operation takes. `declaredOperationSchema` and `operationInputSchemas` join them (operation names are unique across contracts), `operationInputKeys(operation)` gives the `access/execute` keys an operation takes besides `resourceId` and `limit`, and `operationInputFromAccess(operation, input)` picks and parses exactly those. Kingdom's catalog, its input check and its dispatch read these, so a new operation is added here first.
|
|
65
80
|
|
|
66
|
-
- **Archive** (`archiveLibrary`): `documents.read`, `documents.search`, `sessions.write`, `sessions.delete`.
|
|
81
|
+
- **Archive** (`archiveLibrary`): `documents.read`, `documents.search`, `sessions.write`, `sessions.delete`. `archiveSourceOperations` (`sessions.write`, `sessions.delete`) are a paired local Archive acting on its own sessions: Kingdom binds them to its `sourceId`, so their grant takes no lens. `archiveLensRefusal(operation, lens)` says why a grant can't carry its lens (search needs content over every document: no `where`, `content` shown), and `archiveGrantRefusal(scope, lens)` checks a whole row.
|
|
82
|
+
- **GitHub** (`repository`): `commits.read`, `trees.read`, `blobs.read`. A grant pins commits with a `where` over the call's `{ sha }`; reading a commit never reaches its tree, so `repositoryLensRefusal(operation, lens)` (and `repositoryGrantRefusal(scope, lens)` for a row) refuses trees and blobs under a pinned grant.
|
|
67
83
|
|
|
68
84
|
## Agent conversations
|
|
69
85
|
|
|
70
86
|
A conversation wraps a host Foundry's main thread (Kingdom `docs/AGENT-CONVERSATIONS.md`, Foundry `docs/reference/CONVERSATIONS.md`). A member is a Signet on the host Foundry's one `threads` resource; Kingdom admits each call and relays it over the host's Installation socket, and stores no conversation or message.
|
|
71
87
|
|
|
72
|
-
- **
|
|
73
|
-
- **Join requests** may name a resource by kind: `requestSignet` takes `{ resourceKind: 'threads', operations, lens }`, and Kingdom resolves it before review.
|
|
74
|
-
- **Per-entry reads.** `installationInquiries`, `installationSignets`, the socket snapshot and `describe` read each entry on its own. An entry this package can't read is dropped and listed in `dropped`, and the rest is kept. Inside a Signet, resources are read one by one: a resource
|
|
75
|
-
- **Host.** `InstallationSocket` takes `threads`, one handler per operation (`thread.read`, `thread.prompt`, `thread.floor`, `prompt.resolve`, `thread.subscribe`), answering `threadResult(...)` or `threadRefused(code)`. A frame
|
|
76
|
-
- **Member.** An outside agent holds its live views with `SignetSocket`: it authenticates as its Signet's presentation (`authenticateSignet`, a proof bound to `POST /api/v1/access/signetSocket` with `ath`), renews it before the five-minute token expires (`renewPresentation`), and opens one `accessThread` stream per thread: a snapshot, then appends, with Kingdom's `seq`/`resync` rules. Frames are handled in order, one open is in flight per stream, and Kingdom's close carries the reason: after a revision the stream reopens under the new
|
|
88
|
+
- **Terms.** A member's `threads` terms (`threadsTermsSchema`) are `{ view: 'own' | 'thread', delivery: 'note' | 'turn', outbound: 'review' | 'auto', floor: { mayTake } }`, each defaulting to its narrowest value (`threadsTerms(terms)` reads them so). Which threads a member reaches is the grant's lens (a `where` over the thread's `id`), which Kingdom applies at admission. `narrowsThreadsTerms` is the rule for approving someone else's request: `own` < `thread`, `note` < `turn`, `review` < `auto`, `floor.mayTake` only where asked. Kingdom relays the terms on every frame (`terms`).
|
|
89
|
+
- **Join requests** may name a resource by kind: `requestSignet` takes `{ resourceKind: 'threads', operations, lens, terms }`, and Kingdom resolves it before review.
|
|
90
|
+
- **Per-entry reads.** `installationInquiries`, `installationSignets`, the socket snapshot and `describe` read each entry on its own. An entry this package can't read is dropped and listed in `dropped`, and the rest is kept. Inside a Signet, resources are read one by one: a resource this package can't read (a term from a newer Kingdom, terms that don't fit its resource's kind) is dropped, and the Signet stays, so an installation never loses a held Signet, or mistakes a member for revoked, over one resource. The snapshot carries `hostedSignets` (members of this Foundry's conversations) and `hostedSignetRequests` (join requests aimed at its integrations); held Signets carry their `resources`.
|
|
91
|
+
- **Host.** `InstallationSocket` takes `threads`, one handler per operation (`thread.read`, `thread.prompt`, `thread.floor`, `prompt.resolve`, `thread.subscribe`), answering `threadResult(...)` or `threadRefused(code)`. A frame this package can't read is refused `invalid_request` before any handler runs. `thread.subscribe` answers with the member's view and the `journalSeq` it was taken at, and the subscription starts there: `appendThread(subscriptionId, entry)` holds entries pushed meanwhile and never sends one twice. Each append carries a dense per-subscription `seq` (a filtered view skips journal entries, so `journalSeq` can't reveal loss); an entry that can't be sent still spends its `seq`, so Kingdom sees the gap and the member resyncs. A subscription ends on `thread.unsubscribe`, when Kingdom stops re-asserting its lease (`thread.lease`; dropped 90 s after the last one, so one late 45 s sweep is survived), or when the socket drops.
|
|
92
|
+
- **Member.** An outside agent holds its live views with `SignetSocket`: it authenticates as its Signet's presentation (`authenticateSignet`, a proof bound to `POST /api/v1/access/signetSocket` with `ath`), renews it before the five-minute token expires (`renewPresentation`), and opens one `accessThread` stream per thread: a snapshot, then appends, with Kingdom's `seq`/`resync` rules. Frames are handled in order, one open is in flight per stream, and Kingdom's close carries the reason: after a revision the stream reopens under the new revision, while the host is offline it retries with backoff, when the presentation lapsed the socket re-presents and reopens, and on revocation or expiry it ends (`onClosed`). Posts are separate `execute` calls (`thread.prompt`), each admitted and metered. A Foundry holding a conversation Signet opens the same streams over its Installation socket (`threadStreams`, `openThread`).
|
|
77
93
|
|
|
78
94
|
```ts
|
|
79
95
|
import { SignetClient, SignetSocket } from '@inixiative/signet';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/signet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Present Kingdom Signets: the wire contracts, DPoP proofs, device pairing and a renewing client shared by Kingdom, Archive, Foundry and Oracle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@asteasolutions/zod-to-openapi": "^9.1.0",
|
|
37
37
|
"@biomejs/biome": "2.5.2",
|
|
38
|
-
"@inixiative/config": "^0.5.
|
|
38
|
+
"@inixiative/config": "^0.5.174",
|
|
39
|
+
"@inixiative/json-rules": "^3.16.0",
|
|
39
40
|
"@types/bun": "1.4.4",
|
|
40
41
|
"lefthook": "2.1.9",
|
|
41
42
|
"typescript": "6.0.3",
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"access": "public"
|
|
54
55
|
},
|
|
55
56
|
"peerDependencies": {
|
|
56
|
-
"zod": "^4.3.6"
|
|
57
|
+
"zod": "^4.3.6",
|
|
58
|
+
"@inixiative/json-rules": "^3.16.0"
|
|
57
59
|
}
|
|
58
60
|
}
|
package/src/archive.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { type GrantLens, projectionFilters, projectionShows } from './lens';
|
|
3
|
+
import { type OperationScope, scopeRefusal } from './operations';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Archive libraries (Kingdom `docs/kingdom.md`, Archive `docs/KINGDOM.md`). A hosted Archive is an
|
|
5
7
|
* integration with one `archiveLibrary` resource. Kingdom admits each call through a Signet and runs
|
|
6
|
-
* it against that Archive with the integration's credential. The Archive server never sees the lens
|
|
7
|
-
* so Kingdom applies it.
|
|
8
|
+
* it against that Archive with the integration's credential. The Archive server never sees the lens
|
|
9
|
+
* (json-rules layers over the library's base lens), so Kingdom applies it.
|
|
8
10
|
*/
|
|
9
11
|
export const archiveLibraryResourceKind = 'archiveLibrary';
|
|
10
12
|
|
|
@@ -13,20 +15,6 @@ export const archiveDocumentIdSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
|
13
15
|
export const archiveFieldSchema = z.enum(['id', 'title', 'content', 'tags', 'createdAt']);
|
|
14
16
|
export type ArchiveField = z.infer<typeof archiveFieldSchema>;
|
|
15
17
|
|
|
16
|
-
/** Which documents and fields of an `archiveLibrary` a grant reaches; `{}` reaches all of them. */
|
|
17
|
-
export const archiveLensSchema = z.strictObject({
|
|
18
|
-
documentIds: z.array(archiveDocumentIdSchema).max(1000).optional(),
|
|
19
|
-
fields: z.array(archiveFieldSchema).max(6).optional(),
|
|
20
|
-
});
|
|
21
|
-
export type ArchiveLens = z.infer<typeof archiveLensSchema>;
|
|
22
|
-
|
|
23
|
-
const subset = <T>(granted: T[] | undefined, requested: T[] | undefined) =>
|
|
24
|
-
!requested || (granted !== undefined && granted.every((item) => requested.includes(item)));
|
|
25
|
-
|
|
26
|
-
/** `granted` reaches no document or field `requested` doesn't. */
|
|
27
|
-
export const narrowsArchiveLens = (granted: ArchiveLens, requested: ArchiveLens): boolean =>
|
|
28
|
-
subset(granted.documentIds, requested.documentIds) && subset(granted.fields, requested.fields);
|
|
29
|
-
|
|
30
18
|
/** The operations an `archiveLibrary` resource carries. */
|
|
31
19
|
export const archiveOperationSchema = z.enum([
|
|
32
20
|
'documents.read',
|
|
@@ -90,14 +78,20 @@ export const archiveInputSchemas = {
|
|
|
90
78
|
'sessions.delete': z.strictObject({ documentId: archiveDocumentIdSchema }),
|
|
91
79
|
} as const satisfies Record<ArchiveOperation, z.ZodObject>;
|
|
92
80
|
|
|
93
|
-
/** Why a grant of `operation` can't carry `lens`, or null when it can. */
|
|
94
|
-
export const archiveLensRefusal = (operation: ArchiveOperation, lens:
|
|
95
|
-
if (
|
|
96
|
-
|
|
81
|
+
/** Why a grant of `operation` can't carry `lens`, or null when it can. No layers is the base. */
|
|
82
|
+
export const archiveLensRefusal = (operation: ArchiveOperation, lens: GrantLens) => {
|
|
83
|
+
if (lens.layers.length === 0) return null;
|
|
84
|
+
if (isArchiveSourceOperation(operation)) return 'Writing or deleting sessions takes no lens';
|
|
97
85
|
if (
|
|
98
86
|
operation === 'documents.search' &&
|
|
99
|
-
(lens.
|
|
87
|
+
(projectionFilters(lens.projection) || !projectionShows(lens.projection, 'content'))
|
|
100
88
|
)
|
|
101
89
|
return 'Search needs a lens that shows content and is not limited to chosen documents';
|
|
102
90
|
return null;
|
|
103
91
|
};
|
|
92
|
+
|
|
93
|
+
/** `archiveLensRefusal` over every operation a grant row reaches, `full` meaning all of them. */
|
|
94
|
+
export const archiveGrantRefusal = (scope: OperationScope, lens: GrantLens) =>
|
|
95
|
+
scopeRefusal(scope, archiveOperationSchema.options, (operation) =>
|
|
96
|
+
archiveLensRefusal(operation, lens),
|
|
97
|
+
);
|
package/src/client.ts
CHANGED
|
@@ -409,7 +409,7 @@ export const enrollSignet = async (
|
|
|
409
409
|
|
|
410
410
|
/**
|
|
411
411
|
* The Signets held by this installation's integrations, and those issued on them (`hostedSignets`).
|
|
412
|
-
* Read per entry: a Signet whose
|
|
412
|
+
* Read per entry: a Signet whose grants this package can't read is dropped and listed in `dropped`.
|
|
413
413
|
*/
|
|
414
414
|
export const installationSignets = (
|
|
415
415
|
url: string,
|
|
@@ -610,7 +610,7 @@ export class SignetClient {
|
|
|
610
610
|
{ signal, timeoutMs: options.timeoutMs },
|
|
611
611
|
);
|
|
612
612
|
}
|
|
613
|
-
/** Read per entry: a resource whose lens this package can't read is dropped, listed in `dropped`. */
|
|
613
|
+
/** Read per entry: a resource whose lens or terms this package can't read is dropped, listed in `dropped`. */
|
|
614
614
|
async describe(options: SignetRequestOptions = {}) {
|
|
615
615
|
return parseSignetDescription(
|
|
616
616
|
await this.post('describe', { signetId: this.signetId }, undefined, options),
|
package/src/contracts.ts
CHANGED
|
@@ -3,41 +3,33 @@ import {
|
|
|
3
3
|
archiveDocumentIdSchema,
|
|
4
4
|
archiveFieldSchema,
|
|
5
5
|
archiveInputSchemas,
|
|
6
|
-
archiveLensSchema,
|
|
7
|
-
archiveLibraryResourceKind,
|
|
8
6
|
archiveOperationSchema,
|
|
9
|
-
narrowsArchiveLens,
|
|
10
7
|
} from './archive';
|
|
8
|
+
import { githubInputSchemas, githubOperationSchema, gitObjectIdSchema } from './github';
|
|
11
9
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
githubOperationSchema,
|
|
21
|
-
gitObjectIdSchema,
|
|
22
|
-
narrowsRepositoryLens,
|
|
23
|
-
repositoryLensSchema,
|
|
24
|
-
repositoryResourceKind,
|
|
25
|
-
} from './github';
|
|
10
|
+
grantTermsFitKind,
|
|
11
|
+
grantTermsSchema,
|
|
12
|
+
requestedResourceSchema,
|
|
13
|
+
type SignetGrant,
|
|
14
|
+
signetGrantSchema,
|
|
15
|
+
signetResourceSchema,
|
|
16
|
+
} from './grants';
|
|
17
|
+
import { storedLensLayersSchema } from './lens';
|
|
26
18
|
import {
|
|
27
19
|
foundryIdSchema,
|
|
28
|
-
narrowsThreadsLens,
|
|
29
20
|
threadFloorActionSchema,
|
|
30
21
|
threadIdSchema,
|
|
31
22
|
threadInputSchemas,
|
|
32
23
|
threadOperationSchema,
|
|
33
|
-
threadsLensSchema,
|
|
34
|
-
threadsResourceKind,
|
|
35
24
|
threadTextLimit,
|
|
36
25
|
} from './threads';
|
|
37
26
|
|
|
38
27
|
export * from './archive';
|
|
39
28
|
export * from './events';
|
|
40
29
|
export * from './github';
|
|
30
|
+
export * from './grants';
|
|
31
|
+
export * from './lens';
|
|
32
|
+
export * from './operations';
|
|
41
33
|
export * from './threads';
|
|
42
34
|
|
|
43
35
|
const token43 = /^[A-Za-z0-9_-]{43}$/;
|
|
@@ -88,83 +80,12 @@ export const signetKeySchema = publicClientKeySchema.extend({
|
|
|
88
80
|
export type PublicClientKey = z.infer<typeof publicClientKeySchema>;
|
|
89
81
|
export type SignetKey = z.infer<typeof signetKeySchema>;
|
|
90
82
|
|
|
91
|
-
/** Every other resource kind takes no lens. */
|
|
92
|
-
export const emptyLensSchema = z.strictObject({});
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* A grant's lens. `signetResourceSchema` names a resource by id with no `kind`, so the contract can
|
|
96
|
-
* only hold a union of the strict shapes; which shape a grant may carry depends on its resource's
|
|
97
|
-
* kind, and Kingdom checks that with `resourceLensSchema` against the kind it stores (review R9).
|
|
98
|
-
* `{}` parses as the empty Archive lens: "no lens", valid for every kind but `threads`.
|
|
99
|
-
*/
|
|
100
|
-
export const signetLensSchema = z.union([
|
|
101
|
-
archiveLensSchema,
|
|
102
|
-
threadsLensSchema,
|
|
103
|
-
repositoryLensSchema,
|
|
104
|
-
eventsLensSchema,
|
|
105
|
-
]);
|
|
106
|
-
export type SignetLens = z.infer<typeof signetLensSchema>;
|
|
107
|
-
|
|
108
|
-
/** The lens shape a resource of `kind` takes. */
|
|
109
|
-
export const resourceLensSchema = (kind: string) =>
|
|
110
|
-
kind === archiveLibraryResourceKind
|
|
111
|
-
? archiveLensSchema
|
|
112
|
-
: kind === threadsResourceKind
|
|
113
|
-
? threadsLensSchema
|
|
114
|
-
: kind === repositoryResourceKind
|
|
115
|
-
? repositoryLensSchema
|
|
116
|
-
: kind === eventsResourceKind
|
|
117
|
-
? eventsLensSchema
|
|
118
|
-
: emptyLensSchema;
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* `granted` reaches nothing `requested` doesn't, for lenses of the same resource. Approving someone
|
|
122
|
-
* else's request may only narrow; an owner revising their own grant may widen (review U3). A lens of
|
|
123
|
-
* another shape than the request's is refused.
|
|
124
|
-
*/
|
|
125
|
-
export const narrowsSignetLens = (granted: SignetLens, requested: SignetLens): boolean => {
|
|
126
|
-
if ('threads' in granted || 'threads' in requested)
|
|
127
|
-
return 'threads' in granted && 'threads' in requested && narrowsThreadsLens(granted, requested);
|
|
128
|
-
const isEvents = (lens: SignetLens) => 'where' in lens || 'select' in lens || 'name' in lens;
|
|
129
|
-
if (isEvents(granted) || isEvents(requested)) {
|
|
130
|
-
const eventsGranted = eventsLensSchema.safeParse(granted);
|
|
131
|
-
const eventsRequested = eventsLensSchema.safeParse(requested);
|
|
132
|
-
return (
|
|
133
|
-
eventsGranted.success &&
|
|
134
|
-
eventsRequested.success &&
|
|
135
|
-
narrowsEventsLens(eventsGranted.data, eventsRequested.data)
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
if ('commits' in granted || 'commits' in requested) {
|
|
139
|
-
const repositoryGranted = repositoryLensSchema.safeParse(granted);
|
|
140
|
-
const repositoryRequested = repositoryLensSchema.safeParse(requested);
|
|
141
|
-
return (
|
|
142
|
-
repositoryGranted.success &&
|
|
143
|
-
repositoryRequested.success &&
|
|
144
|
-
narrowsRepositoryLens(repositoryGranted.data, repositoryRequested.data)
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
const archiveGranted = archiveLensSchema.safeParse(granted);
|
|
148
|
-
const archiveRequested = archiveLensSchema.safeParse(requested);
|
|
149
|
-
return (
|
|
150
|
-
archiveGranted.success &&
|
|
151
|
-
archiveRequested.success &&
|
|
152
|
-
narrowsArchiveLens(archiveGranted.data, archiveRequested.data)
|
|
153
|
-
);
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
export const signetResourceSchema = z.strictObject({
|
|
157
|
-
resourceId: z.uuid(),
|
|
158
|
-
operations: z.array(z.string().min(1).max(80)).min(1).max(100),
|
|
159
|
-
lens: signetLensSchema.default({}),
|
|
160
|
-
});
|
|
161
83
|
export const signetRecipientSchema = z.discriminatedUnion('recipientModel', [
|
|
162
84
|
z.strictObject({ recipientModel: z.literal('User'), userId: z.uuid() }),
|
|
163
85
|
z.strictObject({ recipientModel: z.literal('Integration'), integrationId: z.uuid() }),
|
|
164
86
|
]);
|
|
165
87
|
export const signetLifecycleSchema = z.enum(['request', 'task', 'ongoing']);
|
|
166
88
|
export type SignetRecipient = z.infer<typeof signetRecipientSchema>;
|
|
167
|
-
export type SignetResource = z.infer<typeof signetResourceSchema>;
|
|
168
89
|
|
|
169
90
|
export const signetEnrollmentSchema = z.strictObject({
|
|
170
91
|
signetId: z.uuid(),
|
|
@@ -206,18 +127,6 @@ const signetTerms = {
|
|
|
206
127
|
/** A local Foundry, Archive or Oracle known to Kingdom by its key. */
|
|
207
128
|
export const installationKindSchema = z.enum(['foundry', 'archive', 'oracle']);
|
|
208
129
|
export type InstallationKind = z.infer<typeof installationKindSchema>;
|
|
209
|
-
/**
|
|
210
|
-
* A resource asked for by kind, and optionally name, when the asker does not know its id: an
|
|
211
|
-
* Installation registering, or a party asking to join a Foundry's `threads` (one per Foundry, so the
|
|
212
|
-
* integration id names it). Kingdom resolves it to the resource's id before anyone reviews it.
|
|
213
|
-
*/
|
|
214
|
-
export const requestedResourceKindSchema = z.strictObject({
|
|
215
|
-
resourceKind: z.string().min(1).max(80),
|
|
216
|
-
resourceName: z.string().trim().min(1).max(120).optional(),
|
|
217
|
-
operations: z.array(z.string().min(1).max(80)).min(1).max(100),
|
|
218
|
-
lens: signetLensSchema.default({}),
|
|
219
|
-
});
|
|
220
|
-
export const requestedResourceSchema = z.union([signetResourceSchema, requestedResourceKindSchema]);
|
|
221
130
|
/** What a party asks for on an integration: resources by id or by kind (review R8). */
|
|
222
131
|
export const signetProposalSchema = z.strictObject({
|
|
223
132
|
...signetTerms,
|
|
@@ -260,7 +169,6 @@ export const signetRequestResponseSchema = z.object({
|
|
|
260
169
|
export type SignetProposal = z.infer<typeof signetProposalSchema>;
|
|
261
170
|
export type RegistrationTerms = z.infer<typeof registrationTermsSchema>;
|
|
262
171
|
export type RequestedOwner = z.infer<typeof requestedOwnerSchema>;
|
|
263
|
-
export type RequestedResource = z.input<typeof requestedResourceSchema>;
|
|
264
172
|
export type RegistrationRequest = z.input<typeof registrationRequestSchema>;
|
|
265
173
|
export type SignetRequest = z.infer<typeof signetRequestSchema>;
|
|
266
174
|
export type SignetRequestResponse = z.infer<typeof signetRequestResponseSchema>;
|
|
@@ -330,18 +238,7 @@ export const installationInquiriesResponseSchema = z.object({
|
|
|
330
238
|
});
|
|
331
239
|
export const inquiryReferenceSchema = z.strictObject({ inquiryId: z.uuid() });
|
|
332
240
|
|
|
333
|
-
/** One resource a Signet reaches, with its stored kind, so its lens can be checked against it. */
|
|
334
|
-
export const signetGrantSchema = z.object({
|
|
335
|
-
resourceId: z.uuid(),
|
|
336
|
-
kind: z.string().min(1).max(80),
|
|
337
|
-
operations: z.array(z.string().min(1).max(80)),
|
|
338
|
-
lens: signetLensSchema,
|
|
339
|
-
});
|
|
340
|
-
export type SignetGrant = z.infer<typeof signetGrantSchema>;
|
|
341
241
|
const grantsSchema = z.array(signetGrantSchema);
|
|
342
|
-
/** Whether a grant's lens is the shape its resource's kind takes. */
|
|
343
|
-
export const grantLensFitsKind = (grant: Pick<SignetGrant, 'kind' | 'lens'>) =>
|
|
344
|
-
resourceLensSchema(grant.kind).safeParse(grant.lens).success;
|
|
345
242
|
|
|
346
243
|
/** Who a Signet is issued to. Recipients run broad (2026-10-18); Kingdom widens issuing in K0. */
|
|
347
244
|
export const signetRecipientModelSchema = z.enum([
|
|
@@ -403,7 +300,8 @@ export const signetDescriptionResourceSchema = z.object({
|
|
|
403
300
|
name: z.string(),
|
|
404
301
|
kind: z.string(),
|
|
405
302
|
integrationId: z.uuid(),
|
|
406
|
-
lens:
|
|
303
|
+
lens: storedLensLayersSchema.optional(),
|
|
304
|
+
terms: grantTermsSchema.optional(),
|
|
407
305
|
});
|
|
408
306
|
export const signetDescriptionSchema = z.object({
|
|
409
307
|
signetId: z.uuid(),
|
|
@@ -498,14 +396,12 @@ export const declaredOperationSchema = z.enum([
|
|
|
498
396
|
...threadOperationSchema.options,
|
|
499
397
|
...archiveOperationSchema.options,
|
|
500
398
|
...githubOperationSchema.options,
|
|
501
|
-
...eventsOperationSchema.options,
|
|
502
399
|
]);
|
|
503
400
|
export type DeclaredOperation = z.infer<typeof declaredOperationSchema>;
|
|
504
401
|
export const operationInputSchemas = {
|
|
505
402
|
...threadInputSchemas,
|
|
506
403
|
...archiveInputSchemas,
|
|
507
404
|
...githubInputSchemas,
|
|
508
|
-
...eventsInputSchemas,
|
|
509
405
|
} as const satisfies Record<DeclaredOperation, z.ZodObject>;
|
|
510
406
|
export type OperationInput<O extends DeclaredOperation> = z.infer<
|
|
511
407
|
(typeof operationInputSchemas)[O]
|
|
@@ -552,7 +448,7 @@ const entryId = (value: unknown) => {
|
|
|
552
448
|
};
|
|
553
449
|
/**
|
|
554
450
|
* Parse each entry on its own and drop the ones that don't parse. A Kingdom ahead of this package
|
|
555
|
-
* (a
|
|
451
|
+
* (a term it doesn't know, a new field shape) then costs only those entries: never the
|
|
556
452
|
* Installation's other grants or its own held Signets. A missing list reads as empty, reported.
|
|
557
453
|
*/
|
|
558
454
|
export function parseEntries<T>(
|
|
@@ -577,12 +473,14 @@ export function parseEntries<T>(
|
|
|
577
473
|
});
|
|
578
474
|
}
|
|
579
475
|
|
|
580
|
-
const
|
|
581
|
-
|
|
476
|
+
const termsFit = (grant: SignetGrant) =>
|
|
477
|
+
grant.resourceModel === 'Project' || grantTermsFitKind(grant.kind, grant.terms)
|
|
478
|
+
? null
|
|
479
|
+
: `a ${grant.kind} resource carries terms of another kind`;
|
|
582
480
|
|
|
583
481
|
/**
|
|
584
482
|
* Signets read per entry, and their resources per entry inside them: a resource this package can't
|
|
585
|
-
* read (a newer
|
|
483
|
+
* read (a newer term, terms that don't fit its kind) is dropped and reported, and the Signet is
|
|
586
484
|
* kept, so the Installation never loses a held Signet (or mistakes a member for revoked) over one
|
|
587
485
|
* resource.
|
|
588
486
|
*/
|
|
@@ -602,7 +500,7 @@ function parseSignetsWithGrants<S extends z.ZodObject<{ resources: typeof grants
|
|
|
602
500
|
entry.resources,
|
|
603
501
|
signetGrantSchema,
|
|
604
502
|
dropped,
|
|
605
|
-
|
|
503
|
+
termsFit,
|
|
606
504
|
),
|
|
607
505
|
}) as z.infer<S>,
|
|
608
506
|
);
|
|
@@ -654,7 +552,7 @@ export function parseInstallationSnapshot(value: unknown): InstallationSnapshot
|
|
|
654
552
|
|
|
655
553
|
export type ParsedSignetDescription = SignetDescription & { dropped: DroppedEntry[] };
|
|
656
554
|
|
|
657
|
-
/** `access/describe`, per entry: a resource whose lens
|
|
555
|
+
/** `access/describe`, per entry: a resource whose lens or terms don't parse, or don't fit its kind, is dropped. */
|
|
658
556
|
export function parseSignetDescription(value: unknown): ParsedSignetDescription {
|
|
659
557
|
const dropped: DroppedEntry[] = [];
|
|
660
558
|
const record = z
|
|
@@ -668,7 +566,10 @@ export function parseSignetDescription(value: unknown): ParsedSignetDescription
|
|
|
668
566
|
operation.resources,
|
|
669
567
|
signetDescriptionResourceSchema,
|
|
670
568
|
dropped,
|
|
671
|
-
(resource) =>
|
|
569
|
+
(resource) =>
|
|
570
|
+
grantTermsFitKind(resource.kind, resource.terms)
|
|
571
|
+
? null
|
|
572
|
+
: 'terms do not fit the resource kind',
|
|
672
573
|
),
|
|
673
574
|
}));
|
|
674
575
|
return { ...signetDescriptionSchema.parse({ ...record, operations }), dropped };
|
package/src/events.ts
CHANGED
|
@@ -2,14 +2,11 @@ import { z } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Integration events (Kingdom `docs/INTEGRATION-EVENTS.md`, Aron 2026-11-01). An integration that
|
|
5
|
-
* receives its provider's events (a webhook endpoint, or a poll where the provider has none)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* each arriving event against the integration's channels the same way. Live only: a channel nobody
|
|
10
|
-
* holds open drops the event.
|
|
5
|
+
* receives its provider's events (a webhook endpoint, or a poll where the provider has none) fans
|
|
6
|
+
* each one out to the Signet grants that reach it: the event kinds are operation leaves under
|
|
7
|
+
* `events.`, picked like any other, and the grant's lens layers filter and trim the event. Live
|
|
8
|
+
* only: a channel nobody holds open drops the event.
|
|
11
9
|
*/
|
|
12
|
-
export const eventsResourceKind = 'events';
|
|
13
10
|
|
|
14
11
|
/** A provider identity an event names: a login or handle, and the provider's id when it gives one. */
|
|
15
12
|
export const eventPartySchema = z.strictObject({
|
|
@@ -49,7 +46,21 @@ export const integrationEventSchema = z.strictObject({
|
|
|
49
46
|
});
|
|
50
47
|
export type IntegrationEvent = z.infer<typeof integrationEventSchema>;
|
|
51
48
|
|
|
52
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The integration itself as a model: account-level events that belong to no repository, channel or
|
|
51
|
+
* team (a person's mentions, review requests and assignments from GitHub's notifications, a Slack DM)
|
|
52
|
+
* hang off it. Each integration that receives events has one `account` resource.
|
|
53
|
+
*/
|
|
54
|
+
export const accountResourceKind = 'account';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An event kind's operation leaf: `events.<provider event>.<action>`, mirroring the provider's own
|
|
58
|
+
* taxonomy (Aron, 2026-11-01). A grant picks it like any operation, a prefix (`events.pull_request.*`)
|
|
59
|
+
* or everything (`full`).
|
|
60
|
+
*/
|
|
61
|
+
export const eventOperationKey = (type: IntegrationEvent['type']) => `events.${type}`;
|
|
62
|
+
|
|
63
|
+
/** The envelope's fields, for the base event lens a provider's catalog declares over the event model. */
|
|
53
64
|
export const integrationEventFieldSchema = z.enum([
|
|
54
65
|
'eventId',
|
|
55
66
|
'integrationId',
|
|
@@ -68,71 +79,22 @@ export const integrationEventFieldSchema = z.enum([
|
|
|
68
79
|
export type IntegrationEventField = z.infer<typeof integrationEventFieldSchema>;
|
|
69
80
|
|
|
70
81
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* restricting what the holder receives; `eventId` and `type` are always sent. The `where` may test
|
|
75
|
-
* fields the `select` leaves out. `name` is what the holder sees when it reads its channels.
|
|
82
|
+
* One append on a channel's stream. `eventId` and `type` ride outside the projected body, so the
|
|
83
|
+
* grant's lens trimming can never drop what a holder needs to dedupe and route; `event` is the
|
|
84
|
+
* envelope as the grant's lens layers project it (json-rules `projectRows`).
|
|
76
85
|
*/
|
|
77
|
-
export const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
export const accessEventsAppendSchema = z.strictObject({
|
|
87
|
+
eventId: integrationEventSchema.shape.eventId,
|
|
88
|
+
type: integrationEventSchema.shape.type,
|
|
89
|
+
event: z.record(z.string(), z.unknown()),
|
|
81
90
|
});
|
|
82
|
-
export type
|
|
83
|
-
|
|
84
|
-
/** What a holder receives from a channel: the event, cut to the channel's `select`. */
|
|
85
|
-
export const selectedEventSchema = integrationEventSchema
|
|
86
|
-
.partial()
|
|
87
|
-
.required({ eventId: true, type: true });
|
|
88
|
-
export type SelectedEvent = z.infer<typeof selectedEventSchema>;
|
|
89
|
-
|
|
90
|
-
/** The event as a channel's `select` lets it through; `eventId` and `type` always go. */
|
|
91
|
-
export const selectEvent = (lens: EventsLens, event: IntegrationEvent): SelectedEvent => {
|
|
92
|
-
if (!lens.select) return event;
|
|
93
|
-
const fields = new Set<IntegrationEventField>([...lens.select, 'eventId', 'type']);
|
|
94
|
-
return Object.fromEntries(
|
|
95
|
-
Object.entries(event).filter(([key]) => fields.has(key as IntegrationEventField)),
|
|
96
|
-
) as SelectedEvent;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* `granted` reaches nothing `requested` doesn't. Conservative and structural, since this package
|
|
103
|
-
* doesn't evaluate json-rules: the granted `where` is the requested one, or `{all: [...]}` holding
|
|
104
|
-
* it (the requested condition plus more); the granted `select` is within the requested one.
|
|
105
|
-
*/
|
|
106
|
-
export const narrowsEventsLens = (granted: EventsLens, requested: EventsLens) => {
|
|
107
|
-
const whereNarrows =
|
|
108
|
-
requested.where === undefined ||
|
|
109
|
-
sameJson(granted.where, requested.where) ||
|
|
110
|
-
(typeof granted.where === 'object' &&
|
|
111
|
-
granted.where !== null &&
|
|
112
|
-
!Array.isArray(granted.where) &&
|
|
113
|
-
Array.isArray((granted.where as { all?: unknown }).all) &&
|
|
114
|
-
(granted.where as { all: unknown[] }).all.some((part) => sameJson(part, requested.where)));
|
|
115
|
-
const selectNarrows =
|
|
116
|
-
!requested.select ||
|
|
117
|
-
(!!granted.select && granted.select.every((field) => requested.select?.includes(field)));
|
|
118
|
-
return whereNarrows && selectNarrows;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
/** The operation an `events` resource carries: hold a channel open. It takes no input. */
|
|
122
|
-
export const eventsOperationSchema = z.enum(['events.subscribe']);
|
|
123
|
-
export type EventsOperation = z.infer<typeof eventsOperationSchema>;
|
|
124
|
-
export const eventsOperationNames: Record<EventsOperation, string> = {
|
|
125
|
-
'events.subscribe': "Receive this integration's events as they happen, filtered by the channel",
|
|
126
|
-
};
|
|
127
|
-
export const eventsInputSchemas = {
|
|
128
|
-
'events.subscribe': z.strictObject({}),
|
|
129
|
-
} as const satisfies Record<EventsOperation, z.ZodObject>;
|
|
91
|
+
export type AccessEventsAppend = z.infer<typeof accessEventsAppendSchema>;
|
|
130
92
|
|
|
131
93
|
/**
|
|
132
94
|
* A channel's live stream is a Kingdom data stream, like a thread's (`accessThread`): its family is
|
|
133
95
|
* the operationId of Kingdom's snapshot route `GET /api/v1/access/events/{resourceId}/{signetId}`,
|
|
134
96
|
* and its name that route's channel key (path fields sorted). The snapshot is always empty: there is
|
|
135
|
-
* no backlog. Each append's payload is one `
|
|
97
|
+
* no backlog. Each append's payload is one `AccessEventsAppend`.
|
|
136
98
|
*/
|
|
137
99
|
export const accessEventsStreamFamily = 'accessEvents';
|
|
138
100
|
export const accessEventsStreamName = (stream: { signetId: string; resourceId: string }) =>
|
package/src/github.ts
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { type GrantLens, projectionFilters } from './lens';
|
|
3
|
+
import { type OperationScope, scopeRefusal } from './operations';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* GitHub repositories (Kingdom `docs/OPERATOR-ACCESS.md` §9). A GitHub integration is one GitHub
|
|
5
7
|
* account; each repository it reaches is a `repository` resource, named `owner/name`. Kingdom admits
|
|
6
8
|
* each call through a Signet and reads the repository with the integration's credential, so the
|
|
7
|
-
* lens is applied by Kingdom.
|
|
9
|
+
* lens is applied by Kingdom. A grant pins commits with a `where` over the call's `{ sha }`; reading a
|
|
10
|
+
* commit never reaches its tree, so a pinned grant reaches no tree or blob.
|
|
8
11
|
*/
|
|
9
12
|
export const repositoryResourceKind = 'repository';
|
|
10
13
|
|
|
11
14
|
/** A Git object id: an immutable commit, tree or blob SHA-1. */
|
|
12
15
|
export const gitObjectIdSchema = z.string().regex(/^[a-f0-9]{40}$/);
|
|
13
16
|
|
|
14
|
-
/**
|
|
15
|
-
* Which commits of a `repository` a grant reaches; `{}` reaches the whole repository. A grant pinned
|
|
16
|
-
* to commits reads only those commits: reading a commit never reaches its tree, so a pinned grant
|
|
17
|
-
* reaches no tree or blob.
|
|
18
|
-
*/
|
|
19
|
-
export const repositoryLensSchema = z.strictObject({
|
|
20
|
-
commits: z.array(gitObjectIdSchema).min(1).max(1000).optional(),
|
|
21
|
-
});
|
|
22
|
-
export type RepositoryLens = z.infer<typeof repositoryLensSchema>;
|
|
23
|
-
|
|
24
|
-
/** `granted` reaches no commit `requested` doesn't. */
|
|
25
|
-
export const narrowsRepositoryLens = (granted: RepositoryLens, requested: RepositoryLens) =>
|
|
26
|
-
!requested.commits ||
|
|
27
|
-
(granted.commits?.every((commit) => requested.commits?.includes(commit)) ?? false);
|
|
28
|
-
|
|
29
17
|
/** The operations a `repository` resource carries. */
|
|
30
18
|
export const githubOperationSchema = z.enum(['commits.read', 'trees.read', 'blobs.read']);
|
|
31
19
|
export type GithubOperation = z.infer<typeof githubOperationSchema>;
|
|
@@ -47,18 +35,16 @@ export const githubInputSchemas = {
|
|
|
47
35
|
} as const satisfies Record<GithubOperation, z.ZodObject>;
|
|
48
36
|
|
|
49
37
|
/** Why a grant of `operation` can't carry `lens`, or null when it can. */
|
|
50
|
-
export const repositoryLensRefusal = (operation: GithubOperation, lens:
|
|
51
|
-
lens.
|
|
38
|
+
export const repositoryLensRefusal = (operation: GithubOperation, lens: GrantLens) =>
|
|
39
|
+
projectionFilters(lens.projection) && operation !== 'commits.read'
|
|
52
40
|
? 'A grant pinned to commits reads only those commits; trees and blobs need an unpinned grant'
|
|
53
41
|
: null;
|
|
54
42
|
|
|
55
|
-
/**
|
|
56
|
-
export const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
objectId: string,
|
|
60
|
-
) =>
|
|
61
|
-
lenses.every(
|
|
62
|
-
(lens) =>
|
|
63
|
-
!repositoryLensRefusal(operation, lens) && (!lens.commits || lens.commits.includes(objectId)),
|
|
43
|
+
/** `repositoryLensRefusal` over every operation a grant row reaches, `full` meaning all of them. */
|
|
44
|
+
export const repositoryGrantRefusal = (scope: OperationScope, lens: GrantLens) =>
|
|
45
|
+
scopeRefusal(scope, githubOperationSchema.options, (operation) =>
|
|
46
|
+
repositoryLensRefusal(operation, lens),
|
|
64
47
|
);
|
|
48
|
+
|
|
49
|
+
/** The row a repository grant's `where` is evaluated over: the call's object, as `sha`. */
|
|
50
|
+
export const repositoryCallRow = (input: { objectId: string }) => ({ sha: input.objectId });
|
package/src/grants.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { lensLayerSchema, storedLensLayersSchema } from './lens';
|
|
3
|
+
import { operationPickSchema, resolvedOperationsSchema } from './operations';
|
|
4
|
+
import { threadsResourceKind, threadsTermsSchema } from './threads';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a Signet revision grants, one row per resource (Aron, 2026-11-01). False polymorphism on
|
|
8
|
+
* `resourceModel`: an `IntegrationResource` row carries its operations (`full`, or the leaves its
|
|
9
|
+
* picks resolved to), its lens layers and its per-kind terms; a `Project` row names a Kingdom project
|
|
10
|
+
* the Signet may be used in, and nothing else. Artifacts may join later.
|
|
11
|
+
*/
|
|
12
|
+
export const grantResourceModelSchema = z.enum(['IntegrationResource', 'Project']);
|
|
13
|
+
export type GrantResourceModel = z.infer<typeof grantResourceModelSchema>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-kind terms that are neither operations nor lens: how a holder takes part. Strict per kind. A
|
|
17
|
+
* request names its resource by id with no kind, so Kingdom checks `grantTermsFitKind` once it has
|
|
18
|
+
* resolved the resource.
|
|
19
|
+
*/
|
|
20
|
+
export const grantTermsSchema = z.strictObject({ threads: threadsTermsSchema.optional() });
|
|
21
|
+
export type GrantTerms = z.infer<typeof grantTermsSchema>;
|
|
22
|
+
|
|
23
|
+
/** The resource kind each terms key belongs to. */
|
|
24
|
+
const termsKinds = { threads: threadsResourceKind } as const satisfies Record<
|
|
25
|
+
keyof GrantTerms,
|
|
26
|
+
string
|
|
27
|
+
>;
|
|
28
|
+
/** Whether `terms` only carry keys that belong to a resource of `kind`. */
|
|
29
|
+
export const grantTermsFitKind = (kind: string, terms: GrantTerms | undefined) =>
|
|
30
|
+
Object.entries(terms ?? {}).every(
|
|
31
|
+
([key, value]) => value === undefined || termsKinds[key as keyof GrantTerms] === kind,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** A threads grant's terms, every one it leaves out at its narrowest. */
|
|
35
|
+
export const threadsTerms = (terms: z.input<typeof grantTermsSchema> | undefined) =>
|
|
36
|
+
threadsTermsSchema.parse(terms?.threads ?? {});
|
|
37
|
+
|
|
38
|
+
const integrationResourceShape = {
|
|
39
|
+
resourceModel: z.literal('IntegrationResource'),
|
|
40
|
+
integrationResourceId: z.uuid(),
|
|
41
|
+
/** Everything the resource offers, now and as its catalog grows, capped live by the baseline. */
|
|
42
|
+
full: z.boolean(),
|
|
43
|
+
/** The resolved leaves; absent when `full`. */
|
|
44
|
+
operations: resolvedOperationsSchema.optional(),
|
|
45
|
+
/** The layers under the base lens; absent means the base. */
|
|
46
|
+
lens: storedLensLayersSchema.optional(),
|
|
47
|
+
terms: grantTermsSchema.optional(),
|
|
48
|
+
};
|
|
49
|
+
const fullOrOperations = (
|
|
50
|
+
row: { full: boolean; operations?: string[] },
|
|
51
|
+
context: z.RefinementCtx,
|
|
52
|
+
) => {
|
|
53
|
+
if (row.full && row.operations !== undefined)
|
|
54
|
+
context.addIssue({
|
|
55
|
+
code: 'custom',
|
|
56
|
+
path: ['operations'],
|
|
57
|
+
message: 'A full grant lists no operations',
|
|
58
|
+
});
|
|
59
|
+
if (!row.full && row.operations === undefined)
|
|
60
|
+
context.addIssue({
|
|
61
|
+
code: 'custom',
|
|
62
|
+
path: ['operations'],
|
|
63
|
+
message: 'A grant that is not full lists its operations',
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const integrationResourceGrantSchema = z
|
|
68
|
+
.strictObject(integrationResourceShape)
|
|
69
|
+
.superRefine(fullOrOperations);
|
|
70
|
+
export const projectGrantSchema = z.strictObject({
|
|
71
|
+
resourceModel: z.literal('Project'),
|
|
72
|
+
projectId: z.uuid(),
|
|
73
|
+
});
|
|
74
|
+
/** One stored grant row, as Kingdom writes it on a revision. */
|
|
75
|
+
export const grantResourceSchema = z.discriminatedUnion('resourceModel', [
|
|
76
|
+
integrationResourceGrantSchema,
|
|
77
|
+
projectGrantSchema,
|
|
78
|
+
]);
|
|
79
|
+
export type GrantResource = z.infer<typeof grantResourceSchema>;
|
|
80
|
+
export type IntegrationResourceGrant = z.infer<typeof integrationResourceGrantSchema>;
|
|
81
|
+
export type ProjectGrant = z.infer<typeof projectGrantSchema>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A grant as a holder reads it: the stored row, plus the resource's `kind` so its terms can be read.
|
|
85
|
+
* Read paths stay loose (a field a newer Kingdom adds is dropped, not fatal).
|
|
86
|
+
*/
|
|
87
|
+
export const signetGrantSchema = z.discriminatedUnion('resourceModel', [
|
|
88
|
+
z
|
|
89
|
+
.object({ ...integrationResourceShape, kind: z.string().min(1).max(80) })
|
|
90
|
+
.superRefine(fullOrOperations),
|
|
91
|
+
z.object(projectGrantSchema.shape),
|
|
92
|
+
]);
|
|
93
|
+
export type SignetGrant = z.infer<typeof signetGrantSchema>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What a party asks for on one resource: `full` or picks, its own lens layer (Kingdom assigns the
|
|
97
|
+
* layer its id and chains it under the base, or under the request when an approver writes it), and
|
|
98
|
+
* terms.
|
|
99
|
+
*/
|
|
100
|
+
const askedOperations = {
|
|
101
|
+
operations: operationPickSchema,
|
|
102
|
+
lens: lensLayerSchema.optional(),
|
|
103
|
+
terms: grantTermsSchema.optional(),
|
|
104
|
+
};
|
|
105
|
+
/** A resource asked for by id. */
|
|
106
|
+
export const signetResourceSchema = z.discriminatedUnion('resourceModel', [
|
|
107
|
+
z.strictObject({
|
|
108
|
+
resourceModel: z.literal('IntegrationResource'),
|
|
109
|
+
integrationResourceId: z.uuid(),
|
|
110
|
+
...askedOperations,
|
|
111
|
+
}),
|
|
112
|
+
projectGrantSchema,
|
|
113
|
+
]);
|
|
114
|
+
export type SignetResource = z.infer<typeof signetResourceSchema>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A resource asked for by kind, and optionally name, when the asker does not know its id: an
|
|
118
|
+
* Installation registering, or a party asking to join a Foundry's `threads` (one per Foundry, so the
|
|
119
|
+
* integration id names it). Kingdom resolves it to the resource's id before anyone reviews it.
|
|
120
|
+
*/
|
|
121
|
+
export const requestedResourceKindSchema = z.strictObject({
|
|
122
|
+
resourceKind: z.string().min(1).max(80),
|
|
123
|
+
resourceName: z.string().trim().min(1).max(120).optional(),
|
|
124
|
+
...askedOperations,
|
|
125
|
+
});
|
|
126
|
+
export const requestedResourceSchema = z.union([signetResourceSchema, requestedResourceKindSchema]);
|
|
127
|
+
export type RequestedResource = z.input<typeof requestedResourceSchema>;
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type ThreadRefusal,
|
|
15
15
|
type ThreadRequestFrame,
|
|
16
16
|
type ThreadSubscribeFrame,
|
|
17
|
-
type
|
|
17
|
+
type ThreadsTerms,
|
|
18
18
|
threadAppendFrameSchema,
|
|
19
19
|
threadLeaseFrameSchema,
|
|
20
20
|
threadRefused,
|
|
@@ -37,7 +37,7 @@ export type ThreadSubscription = {
|
|
|
37
37
|
threadId: string;
|
|
38
38
|
acceptedRevision: number;
|
|
39
39
|
member: ThreadSubscribeFrame['member'];
|
|
40
|
-
|
|
40
|
+
terms: ThreadsTerms;
|
|
41
41
|
};
|
|
42
42
|
/** `overflow`: too many entries arrived while the snapshot was taken; the subscribe is refused and retried. */
|
|
43
43
|
export type ThreadSubscriptionEnd = 'unsubscribed' | 'lease' | 'disconnected' | 'overflow';
|
|
@@ -106,18 +106,6 @@ const maxHeldEntries = 1_000;
|
|
|
106
106
|
/** WebSocket.OPEN, read from the spec rather than whichever WebSocket class was passed. */
|
|
107
107
|
const OPEN = 1;
|
|
108
108
|
|
|
109
|
-
/** An unknown selector form (or a `within`, which no v1 form fills) is refused as `unknown_selector`. */
|
|
110
|
-
const unknownSelector = (frame: Record<string, unknown>) => {
|
|
111
|
-
const threads = z
|
|
112
|
-
.object({ threads: z.object({ form: z.string(), within: z.unknown().optional() }).loose() })
|
|
113
|
-
.loose()
|
|
114
|
-
.safeParse(frame.lens);
|
|
115
|
-
return (
|
|
116
|
-
threads.success &&
|
|
117
|
-
(threads.data.threads.form !== 'ids' || threads.data.threads.within !== undefined)
|
|
118
|
-
);
|
|
119
|
-
};
|
|
120
|
-
|
|
121
109
|
/**
|
|
122
110
|
* Kingdom's live line to an Installation. It proves the installation's key, then receives a
|
|
123
111
|
* snapshot of its inquiries and Signets on connect and again whenever one changes. While the
|
|
@@ -349,11 +337,7 @@ export class InstallationSocket {
|
|
|
349
337
|
const frame = threadRequestFrameSchema.safeParse(raw);
|
|
350
338
|
if (!frame.success) {
|
|
351
339
|
this.options.onError?.(frame.error);
|
|
352
|
-
return this.refuse(
|
|
353
|
-
socket,
|
|
354
|
-
requestId.data,
|
|
355
|
-
unknownSelector(raw) ? 'unknown_selector' : 'invalid_request',
|
|
356
|
-
);
|
|
340
|
+
return this.refuse(socket, requestId.data, 'invalid_request');
|
|
357
341
|
}
|
|
358
342
|
const host = this.options.threads;
|
|
359
343
|
if (!host) return this.refuse(socket, requestId.data, 'failed', 'This host serves no threads');
|
|
@@ -401,11 +385,7 @@ export class InstallationSocket {
|
|
|
401
385
|
const parsed = threadSubscribeFrameSchema.safeParse(raw);
|
|
402
386
|
if (!parsed.success) {
|
|
403
387
|
this.options.onError?.(parsed.error);
|
|
404
|
-
return this.refuse(
|
|
405
|
-
socket,
|
|
406
|
-
requestId.data,
|
|
407
|
-
unknownSelector(raw) ? 'unknown_selector' : 'invalid_request',
|
|
408
|
-
);
|
|
388
|
+
return this.refuse(socket, requestId.data, 'invalid_request');
|
|
409
389
|
}
|
|
410
390
|
const host = this.options.threads;
|
|
411
391
|
if (!host) return this.refuse(socket, requestId.data, 'failed', 'This host serves no threads');
|
|
@@ -416,7 +396,7 @@ export class InstallationSocket {
|
|
|
416
396
|
threadId: frame.threadId,
|
|
417
397
|
acceptedRevision: frame.acceptedRevision,
|
|
418
398
|
member: frame.member,
|
|
419
|
-
|
|
399
|
+
terms: frame.terms,
|
|
420
400
|
leaseUntil: Date.now() + this.leaseMs(),
|
|
421
401
|
sentSeq: null,
|
|
422
402
|
seq: 0,
|
package/src/lens.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LensNarrowing, PathProjection, StoredLens } from '@inixiative/json-rules';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A grant's lens is a chain of json-rules layers over the integration's base lens for the resource's
|
|
6
|
+
* model (Aron, 2026-11-01: "root `where` filters; otherwise a narrowing of shape (pick/omit)… No
|
|
7
|
+
* lens means the default"). The base lives in Kingdom's catalog, as code, under the id
|
|
8
|
+
* `provider:kind`; a grant row stores only the layers under it, as json-rules `StoredLens` records,
|
|
9
|
+
* the request's first and an approver's after it. Every layer ANDs with the ones above it, so an
|
|
10
|
+
* approver can only narrow. This package carries the layers; Kingdom validates, composes
|
|
11
|
+
* (`composeLens`) and applies them (`projectRows`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One layer as a party writes it: a json-rules narrowing without its parent. */
|
|
15
|
+
export type LensLayer = Omit<LensNarrowing, 'parent'>;
|
|
16
|
+
/** One stored layer: its id and the ids it composes under, the base first. Never a base itself. */
|
|
17
|
+
export type StoredLensLayer = Pick<StoredLens, 'id' | 'parents'> & LensLayer;
|
|
18
|
+
|
|
19
|
+
const narrowingPart = {
|
|
20
|
+
root: z.record(z.string(), z.unknown()).optional(),
|
|
21
|
+
mapDefaults: z.record(z.string(), z.unknown()).optional(),
|
|
22
|
+
};
|
|
23
|
+
const layerId = z.string().min(1).max(200);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A layer's envelope only: what is inside `root` and `mapDefaults` is json-rules', unchecked here.
|
|
27
|
+
* Kingdom runs `validateNarrowing` (or `composeLens`) on it before reading it as a `LensLayer`.
|
|
28
|
+
*/
|
|
29
|
+
export const lensLayerSchema = z.strictObject(narrowingPart);
|
|
30
|
+
export const storedLensLayerSchema = z.strictObject({
|
|
31
|
+
id: layerId,
|
|
32
|
+
parents: z.array(layerId).min(1).max(20),
|
|
33
|
+
...narrowingPart,
|
|
34
|
+
});
|
|
35
|
+
export const storedLensLayersSchema = z.array(storedLensLayerSchema).max(20);
|
|
36
|
+
|
|
37
|
+
/** The id of an integration's base lens for a resource kind's model, in Kingdom's catalog. */
|
|
38
|
+
export const baseLensId = (provider: string, kind: string) => `${provider}:${kind}`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A grant's lens as the refusals read it: its own layers (empty = the base) and json-rules'
|
|
42
|
+
* `projectLens` of the composed chain, which Kingdom computes after validating the layers.
|
|
43
|
+
*/
|
|
44
|
+
export type GrantLens = { layers: readonly LensLayer[]; projection: PathProjection };
|
|
45
|
+
|
|
46
|
+
/** Whether the composed lens filters rows anywhere: a `where` on the anchor or along a relation. */
|
|
47
|
+
export const projectionFilters = (projection: PathProjection) =>
|
|
48
|
+
Object.values(projection).some((visit) => visit.whereClauses.length > 0);
|
|
49
|
+
|
|
50
|
+
/** Whether the composed lens shows `field` of its anchor model (the visit keyed by the model alone). */
|
|
51
|
+
export const projectionShows = (projection: PathProjection, field: string) =>
|
|
52
|
+
Object.entries(projection).some(
|
|
53
|
+
([path, visit]) => !path.includes('.') && Object.hasOwn(visit.fields, field),
|
|
54
|
+
);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Operation picks (Aron, 2026-11-01: "any model whole, or whatever group of operations whole, or
|
|
5
|
+
* to any degree of specificity"). A provider's catalog is a flat list of
|
|
6
|
+
* dotted leaf keys per resource kind (`commits.read`, `events.pull_request.review_requested`); the
|
|
7
|
+
* nesting is carried by the dots and derived, never stored. A grant asks for `full` (everything the
|
|
8
|
+
* resource has now and later, capped live by the integration's baseline) or picks at any node: a
|
|
9
|
+
* leaf, or a prefix ending `.*` that takes every leaf under it. Picks resolve to leaves once, when
|
|
10
|
+
* the grant is saved, so a later catalog addition reaches only `full` rows.
|
|
11
|
+
*/
|
|
12
|
+
const segment = '[A-Za-z0-9_-]+';
|
|
13
|
+
export const operationKeySchema = z
|
|
14
|
+
.string()
|
|
15
|
+
.max(80)
|
|
16
|
+
.regex(new RegExp(`^${segment}(?:\\.${segment})*$`));
|
|
17
|
+
/** A leaf key, or a prefix ending `.*`. */
|
|
18
|
+
export const operationPickKeySchema = z
|
|
19
|
+
.string()
|
|
20
|
+
.max(82)
|
|
21
|
+
.regex(new RegExp(`^${segment}(?:\\.${segment})*(?:\\.\\*)?$`));
|
|
22
|
+
|
|
23
|
+
export const operationPickSchema = z.union([
|
|
24
|
+
z.strictObject({ full: z.literal(true) }),
|
|
25
|
+
z.strictObject({ picks: z.array(operationPickKeySchema).min(1).max(100) }),
|
|
26
|
+
]);
|
|
27
|
+
export type OperationPick = z.infer<typeof operationPickSchema>;
|
|
28
|
+
|
|
29
|
+
/** Resolved leaves, as a grant row stores them. Picks of nested events may run past a hundred. */
|
|
30
|
+
export const resolvedOperationsSchema = z
|
|
31
|
+
.array(operationKeySchema)
|
|
32
|
+
.min(1)
|
|
33
|
+
.max(1000)
|
|
34
|
+
.refine((operations) => new Set(operations).size === operations.length, {
|
|
35
|
+
message: 'Each operation is listed once',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a row reaches: `full`, or the leaves its picks resolved to. Not the same axis as
|
|
40
|
+
* `Integration.full` / `Token.full`, which make a delegate act with its owner's whole authority.
|
|
41
|
+
*/
|
|
42
|
+
export type OperationScope = { full: true } | { full: false; operations: string[] };
|
|
43
|
+
|
|
44
|
+
export class OperationPickError extends Error {
|
|
45
|
+
readonly code = 'unknown_operation_pick';
|
|
46
|
+
constructor(readonly pick: string) {
|
|
47
|
+
super(`No operation matches the pick '${pick}'`);
|
|
48
|
+
this.name = 'OperationPickError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const prefixOf = (pick: string) => (pick.endsWith('.*') ? pick.slice(0, -1) : null);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The leaves `picks` reach among `leaves` (a resource kind's catalog keys), in catalog order. A
|
|
56
|
+
* pick that reaches nothing throws `OperationPickError`, so a typo never saves an empty grant.
|
|
57
|
+
*/
|
|
58
|
+
export const resolveOperationPicks = (
|
|
59
|
+
leaves: readonly string[],
|
|
60
|
+
pick: OperationPick,
|
|
61
|
+
): OperationScope => {
|
|
62
|
+
if ('full' in pick) return { full: true };
|
|
63
|
+
const chosen = new Set<string>();
|
|
64
|
+
for (const key of pick.picks) {
|
|
65
|
+
const prefix = prefixOf(key);
|
|
66
|
+
const reached = prefix
|
|
67
|
+
? leaves.filter((leaf) => leaf.startsWith(prefix))
|
|
68
|
+
: leaves.filter((leaf) => leaf === key);
|
|
69
|
+
if (reached.length === 0) throw new OperationPickError(key);
|
|
70
|
+
for (const leaf of reached) chosen.add(leaf);
|
|
71
|
+
}
|
|
72
|
+
return { full: false, operations: leaves.filter((leaf) => chosen.has(leaf)) };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One node of a resource kind's operation tree. `key` is its dotted path, `pick` the pick that
|
|
77
|
+
* takes it (the key for a leaf, `key.*` for a group), and a key that is both a leaf and a prefix is
|
|
78
|
+
* a leaf with children (its own pick then takes only the leaf).
|
|
79
|
+
*/
|
|
80
|
+
export type OperationNode = {
|
|
81
|
+
key: string;
|
|
82
|
+
segment: string;
|
|
83
|
+
leaf: boolean;
|
|
84
|
+
pick: string;
|
|
85
|
+
/** `key.*`, whenever the node has children (a leaf that is also a prefix included). */
|
|
86
|
+
groupPick?: string;
|
|
87
|
+
children: OperationNode[];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The tree the dotted keys describe, in catalog order: what a grant UI offers to pick from. */
|
|
91
|
+
export const operationTree = (leaves: readonly string[]): OperationNode[] => {
|
|
92
|
+
const roots: OperationNode[] = [];
|
|
93
|
+
for (const leaf of leaves) {
|
|
94
|
+
let level = roots;
|
|
95
|
+
const parts = leaf.split('.');
|
|
96
|
+
parts.forEach((part, depth) => {
|
|
97
|
+
const key = parts.slice(0, depth + 1).join('.');
|
|
98
|
+
let node = level.find((candidate) => candidate.key === key);
|
|
99
|
+
if (!node) {
|
|
100
|
+
node = { key, segment: part, leaf: false, pick: `${key}.*`, children: [] };
|
|
101
|
+
level.push(node);
|
|
102
|
+
}
|
|
103
|
+
if (depth < parts.length - 1) node.groupPick = `${key}.*`;
|
|
104
|
+
if (depth === parts.length - 1) {
|
|
105
|
+
node.leaf = true;
|
|
106
|
+
node.pick = key;
|
|
107
|
+
}
|
|
108
|
+
level = node.children;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return roots;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `inner` reaches no operation `outer` doesn't. A full `outer` contains everything; a full `inner`
|
|
116
|
+
* under an `outer` that is not full is never a narrowing, even when `outer` lists every leaf today,
|
|
117
|
+
* because `full` keeps growing with the catalog.
|
|
118
|
+
*/
|
|
119
|
+
export const narrowsOperations = (inner: OperationScope, outer: OperationScope): boolean => {
|
|
120
|
+
if (outer.full) return true;
|
|
121
|
+
if (inner.full) return false;
|
|
122
|
+
return inner.operations.every((operation) => permitsOperation(outer, operation));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Whether `scope` reaches `operation`. */
|
|
126
|
+
export const permitsOperation = (scope: OperationScope, operation: string) =>
|
|
127
|
+
scope.full || scope.operations.includes(operation);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The first refusal among the catalog operations `scope` reaches, or null. A full scope is checked
|
|
131
|
+
* against the whole catalog, so run it again whenever the catalog grows.
|
|
132
|
+
*/
|
|
133
|
+
export const scopeRefusal = <O extends string>(
|
|
134
|
+
scope: OperationScope,
|
|
135
|
+
catalog: readonly O[],
|
|
136
|
+
refusal: (operation: O) => string | null,
|
|
137
|
+
) => {
|
|
138
|
+
for (const operation of catalog) {
|
|
139
|
+
if (!permitsOperation(scope, operation)) continue;
|
|
140
|
+
const reason = refusal(operation);
|
|
141
|
+
if (reason) return reason;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
};
|
package/src/threads.ts
CHANGED
|
@@ -16,32 +16,6 @@ export const threadsResourceKind = 'threads';
|
|
|
16
16
|
export const foundryIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/);
|
|
17
17
|
export const threadIdSchema = foundryIdSchema;
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Selector forms that are named for standing Signets (design §9) but not built. Each fails to parse
|
|
21
|
-
* on every party until a release adds it to `threadSelectorSchema`, so a new form only takes effect
|
|
22
|
-
* once every side has upgraded and never widens access silently.
|
|
23
|
-
*
|
|
24
|
-
* - `{ form: 'withMember' }`: threads opened with the grant's recipient;
|
|
25
|
-
* - `{ form: 'tag', tag }`: threads carrying a tag.
|
|
26
|
-
*/
|
|
27
|
-
export const unbuiltThreadSelectorForms = ['withMember', 'tag'] as const;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* `ids` takes `within` only once a non-`ids` form exists: `within` will hold exactly one such form
|
|
31
|
-
* (never a selector, so it can't nest), set only by approving a request for a selector form narrowed
|
|
32
|
-
* to `ids` (the grant is then `ids ∩ within`), and approval may never drop or widen it. Until then
|
|
33
|
-
* the strict object refuses `within` like any unknown key: structural, and representable in Kingdom's
|
|
34
|
-
* OpenAPI (a `z.never()` placeholder is not).
|
|
35
|
-
*/
|
|
36
|
-
export const idsThreadSelectorSchema = z.strictObject({
|
|
37
|
-
form: z.literal('ids'),
|
|
38
|
-
ids: z.array(threadIdSchema).min(1).max(100),
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
/** Which threads a grant reaches. A discriminated union on `form`; v1 builds only `ids`. */
|
|
42
|
-
export const threadSelectorSchema = z.discriminatedUnion('form', [idsThreadSelectorSchema]);
|
|
43
|
-
export type ThreadSelector = z.infer<typeof threadSelectorSchema>;
|
|
44
|
-
|
|
45
19
|
/** `own`: the member's posts and the sends addressed to it. `thread`: the main thread's messages. */
|
|
46
20
|
export const threadViewSchema = z.enum(['own', 'thread']);
|
|
47
21
|
/** `note`: rides the owner's next turn. `turn`: queued as a turn of its own. */
|
|
@@ -57,38 +31,20 @@ export const threadOutboundSchema = z.enum(['review', 'auto']);
|
|
|
57
31
|
export const threadFloorTermsSchema = z.strictObject({ mayTake: z.boolean().default(false) });
|
|
58
32
|
|
|
59
33
|
/**
|
|
60
|
-
* A member's terms on a `threads` resource
|
|
61
|
-
*
|
|
62
|
-
*
|
|
34
|
+
* A member's terms on a `threads` resource: how it takes part, not which threads it reaches (that
|
|
35
|
+
* is the grant's lens, a json-rules filter Kingdom applies at admission). Every term defaults to its
|
|
36
|
+
* narrowest value, so terms that omit one can only be more careful.
|
|
63
37
|
*/
|
|
64
|
-
export const
|
|
65
|
-
threads: threadSelectorSchema,
|
|
38
|
+
export const threadsTermsSchema = z.strictObject({
|
|
66
39
|
view: threadViewSchema.default('own'),
|
|
67
40
|
delivery: threadDeliverySchema.default('note'),
|
|
68
41
|
outbound: threadOutboundSchema.default('review'),
|
|
69
42
|
floor: threadFloorTermsSchema.default({ mayTake: false }),
|
|
70
43
|
});
|
|
71
|
-
export type
|
|
72
|
-
|
|
73
|
-
/** Is `threadId` inside the selector? For `ids`, Kingdom answers this at admission. */
|
|
74
|
-
export const selectsThread = (selector: ThreadSelector, threadId: string): boolean => {
|
|
75
|
-
switch (selector.form) {
|
|
76
|
-
case 'ids':
|
|
77
|
-
return selector.ids.includes(threadId);
|
|
78
|
-
}
|
|
79
|
-
};
|
|
44
|
+
export type ThreadsTerms = z.infer<typeof threadsTermsSchema>;
|
|
80
45
|
|
|
81
|
-
/**
|
|
82
|
-
export const
|
|
83
|
-
granted: ThreadSelector,
|
|
84
|
-
requested: ThreadSelector,
|
|
85
|
-
): boolean => {
|
|
86
|
-
switch (requested.form) {
|
|
87
|
-
case 'ids':
|
|
88
|
-
// When `within` exists, it must also be kept as asked: never dropped, never added.
|
|
89
|
-
return granted.form === 'ids' && granted.ids.every((id) => requested.ids.includes(id));
|
|
90
|
-
}
|
|
91
|
-
};
|
|
46
|
+
/** The row a threads grant's `where` is evaluated over at admission: the thread, by `id`. */
|
|
47
|
+
export const threadRow = (threadId: string) => ({ id: threadIdSchema.parse(threadId) });
|
|
92
48
|
|
|
93
49
|
const order = <T extends string>(values: readonly T[]) => {
|
|
94
50
|
const rank = (value: T) => values.indexOf(value);
|
|
@@ -100,10 +56,10 @@ const narrowsOutbound = order(threadOutboundSchema.options);
|
|
|
100
56
|
|
|
101
57
|
/**
|
|
102
58
|
* Approving a request may only narrow (`own` < `thread`, `note` < `turn`, `review` < `auto`, and
|
|
103
|
-
* `floor.mayTake` only where it was asked for).
|
|
59
|
+
* `floor.mayTake` only where it was asked for). Terms don't compose like lens layers, so approval
|
|
60
|
+
* checks them here.
|
|
104
61
|
*/
|
|
105
|
-
export const
|
|
106
|
-
narrowsThreadSelector(granted.threads, requested.threads) &&
|
|
62
|
+
export const narrowsThreadsTerms = (granted: ThreadsTerms, requested: ThreadsTerms): boolean =>
|
|
107
63
|
narrowsView(granted.view, requested.view) &&
|
|
108
64
|
narrowsDelivery(granted.delivery, requested.delivery) &&
|
|
109
65
|
narrowsOutbound(granted.outbound, requested.outbound) &&
|
|
@@ -130,7 +86,7 @@ export const threadOperationNames: Record<ThreadOperation, string> = {
|
|
|
130
86
|
* `local` is a member on the host's own machine (its own Codex, a local Herald), joined in-process
|
|
131
87
|
* with no Signet; `signet` is one that reaches the host through Kingdom.
|
|
132
88
|
* Read paths (entries, views) use plain objects, so a host that adds a field never costs an older
|
|
133
|
-
* member its entries;
|
|
89
|
+
* member its entries; terms and request frames stay strict.
|
|
134
90
|
*/
|
|
135
91
|
export const conversationMemberSchema = z.object({
|
|
136
92
|
kind: z.enum(['owner', 'local', 'signet']),
|
|
@@ -263,14 +219,13 @@ export type ThreadResult<O extends keyof typeof threadResultSchemas> = z.infer<
|
|
|
263
219
|
>;
|
|
264
220
|
|
|
265
221
|
/**
|
|
266
|
-
* Why the host refused. The first
|
|
222
|
+
* Why the host refused. The first five are the design's refusals, which Kingdom records as
|
|
267
223
|
* `AccessExecution.status = 'denied'` with this `errorCode` (§5.4). `invalid_request` is a frame the
|
|
268
224
|
* host could not read for any other reason, and `failed` an error inside the host; Kingdom records
|
|
269
225
|
* both as `failed`.
|
|
270
226
|
*/
|
|
271
227
|
export const threadRefusalSchema = z.enum([
|
|
272
228
|
'unknown_thread',
|
|
273
|
-
'unknown_selector',
|
|
274
229
|
'thread_closed',
|
|
275
230
|
'floor_denied',
|
|
276
231
|
'not_invited',
|
|
@@ -281,7 +236,6 @@ export const threadRefusalSchema = z.enum([
|
|
|
281
236
|
export type ThreadRefusal = z.infer<typeof threadRefusalSchema>;
|
|
282
237
|
export const threadDeniedRefusals = [
|
|
283
238
|
'unknown_thread',
|
|
284
|
-
'unknown_selector',
|
|
285
239
|
'thread_closed',
|
|
286
240
|
'floor_denied',
|
|
287
241
|
'not_invited',
|
|
@@ -298,10 +252,11 @@ const relayed = {
|
|
|
298
252
|
/** The `AccessExecution` id: one per admitted call, and the id the answer carries back. */
|
|
299
253
|
requestId: z.uuid(),
|
|
300
254
|
signetId: z.uuid(),
|
|
301
|
-
/** The Signet revision Kingdom admitted the call under; the
|
|
255
|
+
/** The Signet revision Kingdom admitted the call under; the terms are that revision's. */
|
|
302
256
|
acceptedRevision: z.number().int().min(1),
|
|
303
257
|
member: threadMemberSchema,
|
|
304
|
-
|
|
258
|
+
/** The member's terms, as Kingdom admitted the call under them. */
|
|
259
|
+
terms: threadsTermsSchema,
|
|
305
260
|
};
|
|
306
261
|
const requestFrame = <O extends ThreadOperation>(operation: O) =>
|
|
307
262
|
z.strictObject({
|
|
@@ -453,7 +408,7 @@ export const accessThreadCloseReasonSchema = z.enum([
|
|
|
453
408
|
/** The member closed it. */
|
|
454
409
|
'left',
|
|
455
410
|
'revoked',
|
|
456
|
-
/** Any revision closes the stream; the member reopens under the new
|
|
411
|
+
/** Any revision closes the stream; the member reopens under the new revision. */
|
|
457
412
|
'revised',
|
|
458
413
|
'enrollmentRevoked',
|
|
459
414
|
'expired',
|