@inixiative/signet 0.7.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/README.md +3 -1
- package/package.json +2 -2
- package/src/contracts.ts +52 -4
- package/src/github.ts +64 -0
- package/src/holders.ts +110 -0
- package/src/index.ts +2 -0
- package/src/installation.ts +69 -0
- package/src/installationPairing.ts +124 -15
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ This package is the one implementation of that protocol, shared by Kingdom (whic
|
|
|
14
14
|
- `pairInstallation` does the whole pairing (register, request, wait on the socket, confirm the owner, collect) and `holdInstallationSignets` keeps one credential file per Signet the installation's integrations hold, enrolling later grants and dropping revoked ones;
|
|
15
15
|
- `InstallationSocket`, Kingdom's live line to an installation: it proves the key, receives a snapshot of the installation's inquiries and Signets on connect and on every change, keeps the installation's integrations marked online, advertises its viewer URL, and polls the same reads while the socket is down;
|
|
16
16
|
- a **person or their agent** acts as themselves with a Kingdom API token plus a proof from its key: `requestSignet` asks for a Signet on an integration (a `signetRequest` Inquiry, approved at once when the caller manages the integration), `awaitSignet` polls `collectSignet` until it is approved, and `enrollSignet` enrolls the key on a Signet an owner granted directly (`owner/grant`). `saveCollectedSignet` writes either result next to the key;
|
|
17
|
+
- `installation`: the layout every installation shares, under one root per product (a Foundry's `kingdom/`, `~/.kcp`): one directory per Kingdom host with its key and one file per Signet (`installationPaths`, `installationDirectory`, `signetCredentialFile`, `heldCredentials`), `privateDirectory`, and `defaultInstallationName(product)`. `listedSignets`, `heldSignetFile` and `withdrawRegistration` read and act on it. `holdPersonSignets` enrolls the key on Signets issued to a person, under the same per-directory `withHoldLock` as `holdInstallationSignets`, so a person login and an installation sync never enroll over each other;
|
|
18
|
+
- `SignetHolders`, a cache of each held Signet's `access/describe` (with its `holder`), keyed by credential file or by the caller's own `keyOf`, refreshed after `stale()`;
|
|
17
19
|
- `SignetClient`, which renews its access token and signs each call: `describe`, `execute`, task settlement, and the run actions a Foundry uses for inference (`resolveRun`, `delegateRun`, `renewRun`, `revokeRun`), and for a subscription run `releaseSubscription` and `reportSubscriptionLimits`.
|
|
18
20
|
|
|
19
21
|
```ts
|
|
@@ -59,7 +61,7 @@ await signet.describe();
|
|
|
59
61
|
|
|
60
62
|
## Declared operations
|
|
61
63
|
|
|
62
|
-
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
|
|
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; its lens pins a grant to commits, and a pinned grant reaches no tree or blob, since reading a commit never reached its tree). Each declares its operation enum, display names, the input each operation takes, its lens schema and the lens's narrowing. `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.
|
|
63
65
|
|
|
64
66
|
- **Archive** (`archiveLibrary`): `documents.read`, `documents.search`, `sessions.write`, `sessions.delete`. The lens is `{ documentIds?, fields? }` (`{}` reaches every document) and `narrowsArchiveLens` narrows it. `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 a lens (search needs content over every document).
|
|
65
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/signet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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,7 @@
|
|
|
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.164",
|
|
39
39
|
"@types/bun": "1.4.4",
|
|
40
40
|
"lefthook": "2.1.9",
|
|
41
41
|
"typescript": "6.0.3",
|
package/src/contracts.ts
CHANGED
|
@@ -8,6 +8,14 @@ import {
|
|
|
8
8
|
archiveOperationSchema,
|
|
9
9
|
narrowsArchiveLens,
|
|
10
10
|
} from './archive';
|
|
11
|
+
import {
|
|
12
|
+
githubInputSchemas,
|
|
13
|
+
githubOperationSchema,
|
|
14
|
+
gitObjectIdSchema,
|
|
15
|
+
narrowsRepositoryLens,
|
|
16
|
+
repositoryLensSchema,
|
|
17
|
+
repositoryResourceKind,
|
|
18
|
+
} from './github';
|
|
11
19
|
import {
|
|
12
20
|
foundryIdSchema,
|
|
13
21
|
narrowsThreadsLens,
|
|
@@ -21,6 +29,7 @@ import {
|
|
|
21
29
|
} from './threads';
|
|
22
30
|
|
|
23
31
|
export * from './archive';
|
|
32
|
+
export * from './github';
|
|
24
33
|
export * from './threads';
|
|
25
34
|
|
|
26
35
|
const token43 = /^[A-Za-z0-9_-]{43}$/;
|
|
@@ -44,6 +53,21 @@ export const ownerRefSchema = z.object({
|
|
|
44
53
|
});
|
|
45
54
|
export type OwnerRef = z.infer<typeof ownerRefSchema>;
|
|
46
55
|
|
|
56
|
+
/** Kingdom's canonical owner key: `ownerModel:userId:organizationId:spaceId`, absent ids empty. */
|
|
57
|
+
export const ownerKey = (
|
|
58
|
+
owner: Pick<OwnerRef, 'ownerModel'> & {
|
|
59
|
+
[K in 'userId' | 'organizationId' | 'spaceId']?: string | null;
|
|
60
|
+
},
|
|
61
|
+
) =>
|
|
62
|
+
[owner.ownerModel, owner.userId ?? '', owner.organizationId ?? '', owner.spaceId ?? ''].join(':');
|
|
63
|
+
|
|
64
|
+
const uuidOrEmpty =
|
|
65
|
+
'(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})?';
|
|
66
|
+
/** An `ownerKey` string. */
|
|
67
|
+
export const ownerKeySchema = z
|
|
68
|
+
.string()
|
|
69
|
+
.regex(new RegExp(`^(?:${ownerModelSchema.options.join('|')})(?::${uuidOrEmpty}){3}$`));
|
|
70
|
+
|
|
47
71
|
export const publicClientKeySchema = z.strictObject({
|
|
48
72
|
kty: z.literal('EC'),
|
|
49
73
|
crv: z.literal('P-256'),
|
|
@@ -65,7 +89,11 @@ export const emptyLensSchema = z.strictObject({});
|
|
|
65
89
|
* kind, and Kingdom checks that with `resourceLensSchema` against the kind it stores (review R9).
|
|
66
90
|
* `{}` parses as the empty Archive lens: "no lens", valid for every kind but `threads`.
|
|
67
91
|
*/
|
|
68
|
-
export const signetLensSchema = z.union([
|
|
92
|
+
export const signetLensSchema = z.union([
|
|
93
|
+
archiveLensSchema,
|
|
94
|
+
threadsLensSchema,
|
|
95
|
+
repositoryLensSchema,
|
|
96
|
+
]);
|
|
69
97
|
export type SignetLens = z.infer<typeof signetLensSchema>;
|
|
70
98
|
|
|
71
99
|
/** The lens shape a resource of `kind` takes. */
|
|
@@ -74,7 +102,9 @@ export const resourceLensSchema = (kind: string) =>
|
|
|
74
102
|
? archiveLensSchema
|
|
75
103
|
: kind === threadsResourceKind
|
|
76
104
|
? threadsLensSchema
|
|
77
|
-
:
|
|
105
|
+
: kind === repositoryResourceKind
|
|
106
|
+
? repositoryLensSchema
|
|
107
|
+
: emptyLensSchema;
|
|
78
108
|
|
|
79
109
|
/**
|
|
80
110
|
* `granted` reaches nothing `requested` doesn't, for lenses of the same resource. Approving someone
|
|
@@ -84,7 +114,22 @@ export const resourceLensSchema = (kind: string) =>
|
|
|
84
114
|
export const narrowsSignetLens = (granted: SignetLens, requested: SignetLens): boolean => {
|
|
85
115
|
if ('threads' in granted || 'threads' in requested)
|
|
86
116
|
return 'threads' in granted && 'threads' in requested && narrowsThreadsLens(granted, requested);
|
|
87
|
-
|
|
117
|
+
if ('commits' in granted || 'commits' in requested) {
|
|
118
|
+
const repositoryGranted = repositoryLensSchema.safeParse(granted);
|
|
119
|
+
const repositoryRequested = repositoryLensSchema.safeParse(requested);
|
|
120
|
+
return (
|
|
121
|
+
repositoryGranted.success &&
|
|
122
|
+
repositoryRequested.success &&
|
|
123
|
+
narrowsRepositoryLens(repositoryGranted.data, repositoryRequested.data)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const archiveGranted = archiveLensSchema.safeParse(granted);
|
|
127
|
+
const archiveRequested = archiveLensSchema.safeParse(requested);
|
|
128
|
+
return (
|
|
129
|
+
archiveGranted.success &&
|
|
130
|
+
archiveRequested.success &&
|
|
131
|
+
narrowsArchiveLens(archiveGranted.data, archiveRequested.data)
|
|
132
|
+
);
|
|
88
133
|
};
|
|
89
134
|
|
|
90
135
|
export const signetResourceSchema = z.strictObject({
|
|
@@ -408,6 +453,7 @@ export const accessInputSchema = z.strictObject({
|
|
|
408
453
|
floor: threadFloorActionSchema.optional(),
|
|
409
454
|
promptId: foundryIdSchema.optional(),
|
|
410
455
|
answer: threadInputSchemas['prompt.resolve'].shape.answer.optional(),
|
|
456
|
+
objectId: gitObjectIdSchema.optional(),
|
|
411
457
|
});
|
|
412
458
|
export const accessRequestSchema = z.strictObject({
|
|
413
459
|
requestId: z.uuid(),
|
|
@@ -422,18 +468,20 @@ export type AccessRequest = z.infer<typeof accessRequestSchema>;
|
|
|
422
468
|
export const accessResultSchema = z.object({ executionId: z.uuid(), result: z.unknown() });
|
|
423
469
|
|
|
424
470
|
/**
|
|
425
|
-
* Every operation a contract in this package declares (`threads.ts`, `archive.ts`), and the input
|
|
471
|
+
* Every operation a contract in this package declares (`threads.ts`, `archive.ts`, `github.ts`), and the input
|
|
426
472
|
* each takes. Kingdom's `access/execute` check, its catalog and its dispatch read these, so a new
|
|
427
473
|
* operation is added here and nowhere else. Operation names are unique across contracts.
|
|
428
474
|
*/
|
|
429
475
|
export const declaredOperationSchema = z.enum([
|
|
430
476
|
...threadOperationSchema.options,
|
|
431
477
|
...archiveOperationSchema.options,
|
|
478
|
+
...githubOperationSchema.options,
|
|
432
479
|
]);
|
|
433
480
|
export type DeclaredOperation = z.infer<typeof declaredOperationSchema>;
|
|
434
481
|
export const operationInputSchemas = {
|
|
435
482
|
...threadInputSchemas,
|
|
436
483
|
...archiveInputSchemas,
|
|
484
|
+
...githubInputSchemas,
|
|
437
485
|
} as const satisfies Record<DeclaredOperation, z.ZodObject>;
|
|
438
486
|
export type OperationInput<O extends DeclaredOperation> = z.infer<
|
|
439
487
|
(typeof operationInputSchemas)[O]
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub repositories (Kingdom `docs/OPERATOR-ACCESS.md` §9). A GitHub integration is one GitHub
|
|
5
|
+
* account; each repository it reaches is a `repository` resource, named `owner/name`. Kingdom admits
|
|
6
|
+
* each call through a Signet and reads the repository with the integration's credential, so the
|
|
7
|
+
* lens is applied by Kingdom.
|
|
8
|
+
*/
|
|
9
|
+
export const repositoryResourceKind = 'repository';
|
|
10
|
+
|
|
11
|
+
/** A Git object id: an immutable commit, tree or blob SHA-1. */
|
|
12
|
+
export const gitObjectIdSchema = z.string().regex(/^[a-f0-9]{40}$/);
|
|
13
|
+
|
|
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
|
+
/** The operations a `repository` resource carries. */
|
|
30
|
+
export const githubOperationSchema = z.enum(['commits.read', 'trees.read', 'blobs.read']);
|
|
31
|
+
export type GithubOperation = z.infer<typeof githubOperationSchema>;
|
|
32
|
+
|
|
33
|
+
export const githubOperationNames: Record<GithubOperation, string> = {
|
|
34
|
+
'commits.read': 'Read an immutable repository commit',
|
|
35
|
+
'trees.read': 'Read an immutable repository tree',
|
|
36
|
+
'blobs.read': 'Read an immutable repository file',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The input each repository operation takes: the object to read. `access/execute` carries the same
|
|
41
|
+
* field beside `resourceId` and `limit` (`accessInputSchema`).
|
|
42
|
+
*/
|
|
43
|
+
export const githubInputSchemas = {
|
|
44
|
+
'commits.read': z.strictObject({ objectId: gitObjectIdSchema }),
|
|
45
|
+
'trees.read': z.strictObject({ objectId: gitObjectIdSchema }),
|
|
46
|
+
'blobs.read': z.strictObject({ objectId: gitObjectIdSchema }),
|
|
47
|
+
} as const satisfies Record<GithubOperation, z.ZodObject>;
|
|
48
|
+
|
|
49
|
+
/** Why a grant of `operation` can't carry `lens`, or null when it can. */
|
|
50
|
+
export const repositoryLensRefusal = (operation: GithubOperation, lens: RepositoryLens) =>
|
|
51
|
+
lens.commits && operation !== 'commits.read'
|
|
52
|
+
? 'A grant pinned to commits reads only those commits; trees and blobs need an unpinned grant'
|
|
53
|
+
: null;
|
|
54
|
+
|
|
55
|
+
/** Whether every lens a call is admitted under reaches `objectId` for `operation`. */
|
|
56
|
+
export const lensesSelectObject = (
|
|
57
|
+
lenses: RepositoryLens[],
|
|
58
|
+
operation: GithubOperation,
|
|
59
|
+
objectId: string,
|
|
60
|
+
) =>
|
|
61
|
+
lenses.every(
|
|
62
|
+
(lens) =>
|
|
63
|
+
!repositoryLensRefusal(operation, lens) && (!lens.commits || lens.commits.includes(objectId)),
|
|
64
|
+
);
|
package/src/holders.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { SignetClient, SignetHttpError } from './client';
|
|
3
|
+
import {
|
|
4
|
+
type ParsedSignetDescription,
|
|
5
|
+
parseSignetDescription,
|
|
6
|
+
type SignetHolder,
|
|
7
|
+
signetHolderSchema,
|
|
8
|
+
} from './contracts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A held Signet as Kingdom's extended `access/describe` reports it, with its `holder` (null for a
|
|
12
|
+
* person-held Signet). Read per entry (`parseSignetDescription`), so a resource this package can't
|
|
13
|
+
* read costs that resource, never the holder.
|
|
14
|
+
*/
|
|
15
|
+
export const parseHeldSignetDescription = (value: unknown) => ({
|
|
16
|
+
...parseSignetDescription(value),
|
|
17
|
+
holder: z.object({ holder: signetHolderSchema.nullable() }).parse(value).holder,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export interface HolderView {
|
|
21
|
+
/** `described`: Kingdom answered; `revoked`: Kingdom refused the Signet (403); `unavailable`: no answer. */
|
|
22
|
+
status: 'described' | 'revoked' | 'unavailable';
|
|
23
|
+
holder: SignetHolder | null;
|
|
24
|
+
description: ParsedSignetDescription | null;
|
|
25
|
+
checkedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One held Signet: the Kingdom it is from, its id, and the credential file that presents it. */
|
|
29
|
+
export type HeldSignetRef = { url: string; signetId: string; credentialFile: string };
|
|
30
|
+
|
|
31
|
+
export type DescribeHeldSignet<Ref extends HeldSignetRef = HeldSignetRef> = (
|
|
32
|
+
signet: Ref,
|
|
33
|
+
) => Promise<unknown>;
|
|
34
|
+
|
|
35
|
+
const describeWithSignet: DescribeHeldSignet = async ({ url, credentialFile, signetId }) =>
|
|
36
|
+
new SignetClient(url, credentialFile, signetId).post('describe', { signetId }, undefined, {
|
|
37
|
+
timeoutMs: 5_000,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Descriptions per held Signet, cached: read at most once per `ttlMs`, again after the
|
|
42
|
+
* installation socket reports a change (`stale()`), and shared by concurrent readers. Entries are
|
|
43
|
+
* keyed by `keyOf` (default: the credential file), the same key `forget` takes.
|
|
44
|
+
*/
|
|
45
|
+
export class SignetHolders<Ref extends HeldSignetRef = HeldSignetRef> {
|
|
46
|
+
private cache = new Map<string, { view: HolderView; stale: boolean }>();
|
|
47
|
+
private inflight = new Map<string, Promise<HolderView>>();
|
|
48
|
+
private readonly ttlMs: number;
|
|
49
|
+
private readonly describe: DescribeHeldSignet<Ref>;
|
|
50
|
+
private readonly keyOf: (signet: Ref) => string;
|
|
51
|
+
constructor(
|
|
52
|
+
options: {
|
|
53
|
+
ttlMs?: number;
|
|
54
|
+
describe?: DescribeHeldSignet<Ref>;
|
|
55
|
+
keyOf?: (signet: Ref) => string;
|
|
56
|
+
} = {},
|
|
57
|
+
) {
|
|
58
|
+
this.ttlMs = options.ttlMs ?? 10 * 60_000;
|
|
59
|
+
this.describe = options.describe ?? describeWithSignet;
|
|
60
|
+
this.keyOf = options.keyOf ?? ((signet) => signet.credentialFile);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The installation snapshot changed: what Kingdom holds may have too. */
|
|
64
|
+
stale() {
|
|
65
|
+
for (const entry of this.cache.values()) entry.stale = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
forget(key: string) {
|
|
69
|
+
this.cache.delete(key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The cached view when fresh; otherwise a new read, waiting at most `waitMs` before answering with the last one. */
|
|
73
|
+
async get(signet: Ref, waitMs = 3_000): Promise<HolderView> {
|
|
74
|
+
const key = this.keyOf(signet);
|
|
75
|
+
const cached = this.cache.get(key);
|
|
76
|
+
if (cached && !cached.stale && Date.now() - cached.view.checkedAt < this.ttlMs)
|
|
77
|
+
return cached.view;
|
|
78
|
+
let read = this.inflight.get(key);
|
|
79
|
+
if (!read) {
|
|
80
|
+
read = this.read(signet).then((view) => {
|
|
81
|
+
this.cache.set(key, { view, stale: false });
|
|
82
|
+
return view;
|
|
83
|
+
});
|
|
84
|
+
read.finally(() => this.inflight.delete(key)).catch(() => {});
|
|
85
|
+
this.inflight.set(key, read);
|
|
86
|
+
}
|
|
87
|
+
const fallback: HolderView = cached?.view ?? {
|
|
88
|
+
status: 'unavailable',
|
|
89
|
+
holder: null,
|
|
90
|
+
description: null,
|
|
91
|
+
checkedAt: Date.now(),
|
|
92
|
+
};
|
|
93
|
+
return Promise.race([
|
|
94
|
+
read,
|
|
95
|
+
new Promise<HolderView>((resolve) => setTimeout(() => resolve(fallback), waitMs).unref?.()),
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async read(signet: Ref): Promise<HolderView> {
|
|
100
|
+
const checkedAt = Date.now();
|
|
101
|
+
try {
|
|
102
|
+
const description = parseHeldSignetDescription(await this.describe(signet));
|
|
103
|
+
return { status: 'described', holder: description.holder, description, checkedAt };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const status =
|
|
106
|
+
error instanceof SignetHttpError && error.status === 403 ? 'revoked' : 'unavailable';
|
|
107
|
+
return { status, holder: null, description: null, checkedAt };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { chmod, lstat, mkdir, readdir } from 'node:fs/promises';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
generateSignetKey,
|
|
7
|
+
kingdomUrl,
|
|
8
|
+
type SignetCredential,
|
|
9
|
+
signetCredentialSchema,
|
|
10
|
+
} from './client';
|
|
11
|
+
import { readPrivateJson, writePrivateJson } from './files';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where an installation keeps what it holds for one Kingdom under `root`, without creating
|
|
15
|
+
* anything: one directory per Kingdom host, its key, and one file per Signet.
|
|
16
|
+
*/
|
|
17
|
+
export function installationPaths(root: string, url: string) {
|
|
18
|
+
const directory = join(resolve(root), new URL(kingdomUrl(url)).host);
|
|
19
|
+
return { directory, keyFile: join(directory, 'installation-key.json') };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `installationPaths`, creating the installation key on first use. */
|
|
23
|
+
export async function installationDirectory(root: string, url: string) {
|
|
24
|
+
const paths = installationPaths(root, url);
|
|
25
|
+
if (!existsSync(paths.keyFile)) await writePrivateJson(paths.keyFile, generateSignetKey());
|
|
26
|
+
return paths;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const signetCredentialFile = (directory: string, signetId: string) =>
|
|
30
|
+
join(directory, `signet-${signetId}.json`);
|
|
31
|
+
|
|
32
|
+
/** Creates `path` as a 0700 directory owned by this user, or makes an existing one so. */
|
|
33
|
+
export async function privateDirectory(path: string) {
|
|
34
|
+
const directory = resolve(path);
|
|
35
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
36
|
+
const stat = await lstat(directory);
|
|
37
|
+
if (
|
|
38
|
+
!stat.isDirectory() ||
|
|
39
|
+
stat.isSymbolicLink() ||
|
|
40
|
+
(process.getuid && stat.uid !== process.getuid())
|
|
41
|
+
)
|
|
42
|
+
throw Error(`${directory} must be a directory owned by this user`);
|
|
43
|
+
await chmod(directory, 0o700);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The name an installation registers under unless its person picks one: `<product> on <host>`. */
|
|
47
|
+
export const defaultInstallationName = (product: string) =>
|
|
48
|
+
`${product} on ${hostname()}`.slice(0, 120);
|
|
49
|
+
|
|
50
|
+
export type HeldCredential = SignetCredential & { credentialFile: string };
|
|
51
|
+
|
|
52
|
+
const credentialFileName = /^signet-[0-9a-f-]{36}\.json$/;
|
|
53
|
+
|
|
54
|
+
/** Every Signet credential file in one Kingdom's installation directory that parses; others are skipped. */
|
|
55
|
+
export async function heldCredentials(directory: string): Promise<HeldCredential[]> {
|
|
56
|
+
const names = await readdir(directory).catch(() => [] as string[]);
|
|
57
|
+
const held = await Promise.all(
|
|
58
|
+
names
|
|
59
|
+
.filter((name) => credentialFileName.test(name))
|
|
60
|
+
.map(async (name) => {
|
|
61
|
+
const credentialFile = join(directory, name);
|
|
62
|
+
const parsed = signetCredentialSchema.safeParse(
|
|
63
|
+
await readPrivateJson(credentialFile).catch(() => null),
|
|
64
|
+
);
|
|
65
|
+
return parsed.success ? [{ ...parsed.data, credentialFile }] : [];
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
return held.flat();
|
|
69
|
+
}
|
|
@@ -2,14 +2,17 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { mkdir, rm, stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import {
|
|
5
|
+
cancelInstallationInquiry,
|
|
5
6
|
collectSignet,
|
|
6
7
|
enrollInstallationSignet,
|
|
7
|
-
|
|
8
|
+
enrollSignet,
|
|
8
9
|
installationInquiries,
|
|
10
|
+
installationSignets,
|
|
9
11
|
kingdomUrl,
|
|
10
12
|
registerInstallation,
|
|
11
13
|
requestRegistration,
|
|
12
14
|
SignetHttpError,
|
|
15
|
+
type SignetRequestOptions,
|
|
13
16
|
saveCollectedSignet,
|
|
14
17
|
signetCredentialSchema,
|
|
15
18
|
} from './client';
|
|
@@ -19,20 +22,10 @@ import type {
|
|
|
19
22
|
OwnerRef,
|
|
20
23
|
RegistrationRequest,
|
|
21
24
|
} from './contracts';
|
|
22
|
-
import { readPrivateJson
|
|
25
|
+
import { readPrivateJson } from './files';
|
|
26
|
+
import { installationDirectory, installationPaths, signetCredentialFile } from './installation';
|
|
23
27
|
import { InstallationSocket } from './installationSocket';
|
|
24
28
|
|
|
25
|
-
/** Where an installation keeps what it holds for one Kingdom: its key and one file per Signet. */
|
|
26
|
-
export async function installationDirectory(root: string, url: string) {
|
|
27
|
-
const directory = join(root, new URL(kingdomUrl(url)).host);
|
|
28
|
-
const keyFile = join(directory, 'installation-key.json');
|
|
29
|
-
if (!existsSync(keyFile)) await writePrivateJson(keyFile, generateSignetKey());
|
|
30
|
-
return { directory, keyFile };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export const signetCredentialFile = (directory: string, signetId: string) =>
|
|
34
|
-
join(directory, `signet-${signetId}.json`);
|
|
35
|
-
|
|
36
29
|
export type HeldSignet = {
|
|
37
30
|
signetId: string;
|
|
38
31
|
integrationId: string;
|
|
@@ -50,8 +43,12 @@ const heldEnrollment = async (credentialFile: string) => {
|
|
|
50
43
|
|
|
51
44
|
const lockStaleMs = 60_000;
|
|
52
45
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
46
|
+
/**
|
|
47
|
+
* One holder at a time per installation directory, so two processes never enroll or write
|
|
48
|
+
* credentials over each other. Every hold in this package takes it; take it to write a Signet
|
|
49
|
+
* credential into the directory by other means.
|
|
50
|
+
*/
|
|
51
|
+
export async function withHoldLock<T>(directory: string, fn: () => Promise<T>): Promise<T> {
|
|
55
52
|
const lock = join(directory, '.hold-lock');
|
|
56
53
|
for (const started = Date.now(); ; ) {
|
|
57
54
|
try {
|
|
@@ -112,6 +109,118 @@ export async function holdInstallationSignets(
|
|
|
112
109
|
});
|
|
113
110
|
}
|
|
114
111
|
|
|
112
|
+
export type PersonSignet = {
|
|
113
|
+
signetId: string;
|
|
114
|
+
/** The Signet's current revision, as the person read it (`/me/overview`); Kingdom refuses a stale one. */
|
|
115
|
+
expectedRevision: number;
|
|
116
|
+
integrationId: string;
|
|
117
|
+
owner?: OwnerRef;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type HeldPersonSignet = {
|
|
121
|
+
signetId: string;
|
|
122
|
+
integrationId: string;
|
|
123
|
+
owner?: OwnerRef;
|
|
124
|
+
credentialFile: string;
|
|
125
|
+
/** False when a credential file was already held and kept. */
|
|
126
|
+
enrolled: boolean;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Enrolls this installation's key on Signets issued to the person a Kingdom API token belongs to,
|
|
131
|
+
* under the same lock as `holdInstallationSignets`, so a person login and an installation sync
|
|
132
|
+
* never enroll over each other. A Signet whose credential file already parses is kept unless
|
|
133
|
+
* `replace` is set. The token authorizes the enrollment and is never written.
|
|
134
|
+
*/
|
|
135
|
+
export async function holdPersonSignets(
|
|
136
|
+
url: string,
|
|
137
|
+
keyFile: string,
|
|
138
|
+
directory: string,
|
|
139
|
+
apiToken: string,
|
|
140
|
+
signets: readonly PersonSignet[],
|
|
141
|
+
options: SignetRequestOptions & { name: string; replace?: boolean },
|
|
142
|
+
): Promise<HeldPersonSignet[]> {
|
|
143
|
+
const { name, replace, ...request } = options;
|
|
144
|
+
return withHoldLock(directory, async () => {
|
|
145
|
+
const held: HeldPersonSignet[] = [];
|
|
146
|
+
for (const signet of signets) {
|
|
147
|
+
const credentialFile = signetCredentialFile(directory, signet.signetId);
|
|
148
|
+
const kept = !replace && (await heldEnrollment(credentialFile));
|
|
149
|
+
if (!kept) {
|
|
150
|
+
const enrolled = await enrollSignet(
|
|
151
|
+
url,
|
|
152
|
+
keyFile,
|
|
153
|
+
apiToken,
|
|
154
|
+
{ signetId: signet.signetId, expectedRevision: signet.expectedRevision, name },
|
|
155
|
+
request,
|
|
156
|
+
);
|
|
157
|
+
await saveCollectedSignet(credentialFile, url, keyFile, {
|
|
158
|
+
...enrolled,
|
|
159
|
+
integrationId: signet.integrationId,
|
|
160
|
+
...(signet.owner ? { owner: signet.owner } : {}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
held.push({
|
|
164
|
+
signetId: signet.signetId,
|
|
165
|
+
integrationId: signet.integrationId,
|
|
166
|
+
...(signet.owner ? { owner: signet.owner } : {}),
|
|
167
|
+
credentialFile,
|
|
168
|
+
enrolled: !kept,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return held;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Which Signets each Kingdom lists for this installation: one read per Kingdom; null when it refused or is unreachable. */
|
|
176
|
+
export async function listedSignets(root: string, urls: readonly string[]) {
|
|
177
|
+
const listed = new Map<string, Set<string> | null>();
|
|
178
|
+
await Promise.all(
|
|
179
|
+
[...new Set(urls)].map(async (url) => {
|
|
180
|
+
const { keyFile } = installationPaths(root, url);
|
|
181
|
+
try {
|
|
182
|
+
if (!existsSync(keyFile)) throw Error('No installation key');
|
|
183
|
+
const { signets } = await installationSignets(url, keyFile, { timeoutMs: 5000 });
|
|
184
|
+
listed.set(url, new Set(signets.map((signet) => signet.signetId)));
|
|
185
|
+
} catch {
|
|
186
|
+
listed.set(url, null);
|
|
187
|
+
}
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
return listed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The local file of a Signet this installation holds, enrolling it now if Kingdom lists it but no file exists yet. */
|
|
194
|
+
export async function heldSignetFile(root: string, url: string, signetId: string) {
|
|
195
|
+
const { keyFile, directory } = await installationDirectory(root, url);
|
|
196
|
+
const file = signetCredentialFile(directory, signetId);
|
|
197
|
+
if (existsSync(file)) return file;
|
|
198
|
+
const listed = (await installationSignets(url, keyFile)).signets.filter(
|
|
199
|
+
(signet) => signet.signetId === signetId,
|
|
200
|
+
);
|
|
201
|
+
if (!listed.length) throw Error(`Kingdom does not list Signet ${signetId} for this installation`);
|
|
202
|
+
await holdInstallationSignets(url, keyFile, directory, listed);
|
|
203
|
+
if (!existsSync(file)) throw Error(`Signet ${signetId} could not be enrolled here`);
|
|
204
|
+
return file;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Withdraws every open registration of this installation, so a later approval in Kingdom mints no
|
|
209
|
+
* integration nothing collects. One pairing runs at a time per installation, so every open
|
|
210
|
+
* registration belongs to a pairing that no longer waits. Kingdom's refusals are thrown.
|
|
211
|
+
*/
|
|
212
|
+
export async function withdrawRegistration(root: string, url: string) {
|
|
213
|
+
const origin = kingdomUrl(url);
|
|
214
|
+
const { keyFile } = installationPaths(root, origin);
|
|
215
|
+
if (!existsSync(keyFile)) return [];
|
|
216
|
+
const { inquiries } = await installationInquiries(origin, keyFile);
|
|
217
|
+
const open = inquiries.filter(
|
|
218
|
+
(inquiry) => inquiry.type === 'registerIntegration' && openStatuses.has(inquiry.status),
|
|
219
|
+
);
|
|
220
|
+
for (const inquiry of open) await cancelInstallationInquiry(origin, keyFile, inquiry.id);
|
|
221
|
+
return open.map((inquiry) => inquiry.id);
|
|
222
|
+
}
|
|
223
|
+
|
|
115
224
|
export type InstallationPairing = {
|
|
116
225
|
url: string;
|
|
117
226
|
root: string;
|