@astrale-os/kernel-protocol 0.5.0-beta.68 → 0.5.0-beta.69

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.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Private JWT claim of the self credential a durable step's Domain signs for the Kernel: the id of
3
+ * the Execution the step belongs to. After a consented issuer change, the Kernel accepts the
4
+ * replaced issuer past its window only for a credential naming an unfinished run of its Domain
5
+ * that was in flight at that change.
6
+ */
7
+ export declare const EXECUTION_CLAIM = "execution";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Private JWT claim of the self credential a durable step's Domain signs for the Kernel: the id of
3
+ * the Execution the step belongs to. After a consented issuer change, the Kernel accepts the
4
+ * replaced issuer past its window only for a credential naming an unfinished run of its Domain
5
+ * that was in flight at that change.
6
+ */
7
+ export const EXECUTION_CLAIM = 'execution';
@@ -1,3 +1,4 @@
1
+ export { EXECUTION_CLAIM } from './claims.js';
1
2
  export { acceptConfiguration, configuration, type Configuration, type ConfigurationOptions, } from './configuration.js';
2
3
  export { DiscoveryError, type DiscoveryErrorCode } from './error.js';
3
4
  export { hosted } from './hosted.js';
@@ -1,3 +1,4 @@
1
+ export { EXECUTION_CLAIM } from './claims.js';
1
2
  export { acceptConfiguration, configuration, } from './configuration.js';
2
3
  export { DiscoveryError } from './error.js';
3
4
  export { hosted } from './hosted.js';
@@ -26,7 +26,10 @@
26
26
  * `protocol/invocation`, `protocol/routes` and `protocol/schema/installation/introspection` no
27
27
  * longer import this folder (`protocol/invocation/legacy/` and `protocol/routes/legacy/` go under
28
28
  * their own headers), and the version-1 branch of the Delivery v2 `acceptRequest`, which then
29
- * returns only its own `Request`.
29
+ * returns only its own `Request`. The installed listing's legacy pin goes with the folder: the
30
+ * `legacy` variant of `InstalledPin` and its `installedPin` branch in
31
+ * `protocol/schema/installation/introspection/codec.ts`, with the `publication` branch of
32
+ * `runtime/schema/installation/source/document.ts` `installedPin` that produces it.
30
33
  *
31
34
  * The frozen v3 projection of a `DomainRelease` (`projection.ts`, served at `domain.json` by v4
32
35
  * deployments) and its golden vectors have their own, later deletion condition: no pre-v4 Kernel
@@ -5,4 +5,15 @@ export declare function firstCredentialIsAmbient(entry: Route): boolean;
5
5
  export declare function specificHeaderWires(entry: Route): ReadonlySet<string>;
6
6
  export declare function putCredential(entry: Route, url: URL, headers: Record<string, string>, credential: string): void;
7
7
  export declare function withoutCredentialHeaders(entry: Route, input: Readonly<Record<string, string>>): Record<string, string>;
8
+ /** A credential value together with the request channel that actually carried it. */
9
+ export interface LocatedCredential {
10
+ readonly value: string;
11
+ readonly in: SingleCredentialSource['in'];
12
+ }
8
13
  export declare function readCredential(entry: Route, url: URL, headers: Record<string, string>): string | undefined;
14
+ /**
15
+ * Read the request credential and report which declared channel supplied it. A `cookie` channel is
16
+ * ambient (the browser attaches it automatically), which the Server uses to decide CSRF defense; a
17
+ * `header` (Bearer) or `query` channel is caller-chosen and not CSRF-exposed.
18
+ */
19
+ export declare function locateCredential(entry: Route, url: URL, headers: Record<string, string>): LocatedCredential | undefined;
@@ -52,7 +52,14 @@ export function withoutCredentialHeaders(entry, input) {
52
52
  return output;
53
53
  }
54
54
  export function readCredential(entry, url, headers) {
55
- const found = [];
55
+ return locateCredential(entry, url, headers)?.value;
56
+ }
57
+ /**
58
+ * Read the request credential and report which declared channel supplied it. A `cookie` channel is
59
+ * ambient (the browser attaches it automatically), which the Server uses to decide CSRF defense; a
60
+ * `header` (Bearer) or `query` channel is caller-chosen and not CSRF-exposed.
61
+ */
62
+ export function locateCredential(entry, url, headers) {
56
63
  if (entry.credential === undefined) {
57
64
  const authorization = headers.authorization;
58
65
  if (authorization === undefined)
@@ -60,8 +67,9 @@ export function readCredential(entry, url, headers) {
60
67
  const matched = /^Bearer[ \t]+([^\s]+)$/iu.exec(authorization.trim());
61
68
  if (matched === null)
62
69
  invalidRequest('Authorization must contain one Bearer credential.');
63
- return matched[1];
70
+ return { value: matched[1], in: 'header' };
64
71
  }
72
+ const found = [];
65
73
  for (const source of credentialSources(entry)) {
66
74
  let value;
67
75
  if (source.in === 'header') {
@@ -78,7 +86,7 @@ export function readCredential(entry, url, headers) {
78
86
  value = cookie(headers.cookie, source.name);
79
87
  }
80
88
  if (value !== undefined)
81
- found.push(value);
89
+ found.push({ value, in: source.in });
82
90
  }
83
91
  if (found.length > 1)
84
92
  invalidRequest('Request supplies more than one Astrale credential channel.');
@@ -1,6 +1,6 @@
1
1
  import { NodePath } from '@astrale-os/kernel-core/path';
2
2
  import * as portable from '@astrale-os/kernel-dsl/value';
3
- import { readCredential, specificHeaderWires, withoutCredentialHeaders } from './credential.js';
3
+ import { locateCredential, specificHeaderWires, withoutCredentialHeaders } from './credential.js';
4
4
  import { fail, invalidRequest } from './error.js';
5
5
  import { isReservedHeader } from './mapping.js';
6
6
  import { methodMatches } from './match.js';
@@ -44,7 +44,8 @@ export function decodeInvocation(entry, request, domain, routing) {
44
44
  throw cause;
45
45
  }
46
46
  const headers = normalizeHeaders(request.headers);
47
- const credential = readCredential(entry, url, headers);
47
+ const located = locateCredential(entry, url, headers);
48
+ const credential = located?.value;
48
49
  const routed = routing(request);
49
50
  const applicationHeaders = withoutCredentialHeaders(entry, headers);
50
51
  for (const [name, wire] of Object.entries(entry.query ?? {})) {
@@ -130,6 +131,7 @@ export function decodeInvocation(entry, request, domain, routing) {
130
131
  return Object.freeze({
131
132
  call: Object.freeze({ target, input }),
132
133
  ...(credential === undefined ? {} : { credential }),
134
+ ...(located === undefined ? {} : { credentialIn: located.in }),
133
135
  ...(routed === undefined ? {} : { routing: routed }),
134
136
  });
135
137
  }
@@ -5,6 +5,8 @@ import type { HttpMethod } from '../release/route.js';
5
5
  export interface Invocation {
6
6
  readonly call: Call;
7
7
  readonly credential?: string;
8
+ /** The request channel that carried `credential`; `cookie` is ambient (CSRF-exposed). */
9
+ readonly credentialIn?: 'header' | 'cookie' | 'query';
8
10
  readonly routing?: Via | PublicationVia;
9
11
  readonly method?: Exclude<HttpMethod, '*'>;
10
12
  }
@@ -1,5 +1,5 @@
1
1
  import type { introspection } from '@astrale-os/kernel-core/schema';
2
- import type { DomainBundle, DomainDependencies, DomainInfo, Limits, ResultFor } from './result.js';
2
+ import type { DomainBundle, DomainDependencies, DomainInfo, InstalledRelease, Limits, ResultFor } from './result.js';
3
3
  export declare const MEDIA_TYPE: 'application/vnd.astrale.schema-introspection.v3+json';
4
4
  export declare function encode<Input extends introspection.Introspection>(input: Input, result: ResultFor<Input>): Promise<Uint8Array>;
5
5
  export declare function decode<Input extends introspection.Introspection>(input: Input, bytes: Uint8Array, limits: Limits): Promise<ResultFor<Input>>;
@@ -7,7 +7,7 @@ export declare function decode<Input extends introspection.Introspection>(input:
7
7
  * Package-private: admit one decoded evidence value for an admitted request. Shared with the
8
8
  * pre-R1 representation in `../legacy/`, which decodes other bytes into this same wire value.
9
9
  */
10
- export declare function acceptEvidence(request: introspection.Introspection, decoded: unknown, limits: Limits): DomainInfo | DomainBundle | DomainDependencies;
10
+ export declare function acceptEvidence(request: introspection.Introspection, decoded: unknown, limits: Limits): DomainInfo | DomainBundle | DomainDependencies | readonly InstalledRelease[];
11
11
  /** Package-private: admit the evidence bounds a request declares. Shared with `../legacy/`. */
12
12
  export declare function admitLimits(request: introspection.Introspection, limits: Limits): void;
13
13
  /** Admit dependency bindings: origin-ordered, one per origin, each label agreeing with its revisions. */
@@ -1,3 +1,4 @@
1
+ import { is as isIssuer } from '@astrale-os/kernel-core/auth/issuer';
1
2
  import { patterns } from '@astrale-os/kernel-dsl/v1/addressing';
2
3
  import * as bundle from '@astrale-os/kernel-dsl/v1/bundle/transport';
3
4
  import { revision as schemaRevision } from '@astrale-os/kernel-dsl/v1/schema/revision';
@@ -19,12 +20,16 @@ const ENCODE_LIMITS = Object.freeze({
19
20
  });
20
21
  export async function encode(input, result) {
21
22
  const request = admitRequest(input);
22
- const wire = request.kind === 'bundle'
23
- ? encodeDomainBundle(result)
24
- : request.kind === 'dependencies'
25
- ? encodeDomainDependencies(result)
26
- : encodeDomainInfo(result);
27
- proveRequestedOrigin(request, result);
23
+ const wire = request.kind === 'installed'
24
+ ? acceptInstalledReleases(result)
25
+ : request.kind === 'bundle'
26
+ ? encodeDomainBundle(result)
27
+ : request.kind === 'dependencies'
28
+ ? encodeDomainDependencies(result)
29
+ : encodeDomainInfo(result);
30
+ if (request.kind !== 'installed') {
31
+ proveRequestedOrigin(request, result);
32
+ }
28
33
  return encoder.encode(json.serialize(wire, { limits: ENCODE_LIMITS }));
29
34
  }
30
35
  export async function decode(input, bytes, limits) {
@@ -57,6 +62,8 @@ export async function decode(input, bytes, limits) {
57
62
  * pre-R1 representation in `../legacy/`, which decodes other bytes into this same wire value.
58
63
  */
59
64
  export function acceptEvidence(request, decoded, limits) {
65
+ if (request.kind === 'installed')
66
+ return acceptInstalledReleases(decoded);
60
67
  const result = request.kind === 'bundle'
61
68
  ? decodeDomainBundle(decoded, limits.maximumBundleBytes)
62
69
  : request.kind === 'dependencies'
@@ -125,6 +132,114 @@ function acceptDependencyBinding(input) {
125
132
  binding: expected,
126
133
  });
127
134
  }
135
+ /** Admit the installed listing: origin-ordered, one release per origin, each pin of one kind. */
136
+ function acceptInstalledReleases(input) {
137
+ if (!Array.isArray(input))
138
+ invalid('Installed releases are invalid.');
139
+ const releases = input.map(acceptInstalledRelease);
140
+ for (const [index, installed] of releases.entries()) {
141
+ const previous = releases[index - 1];
142
+ if (previous !== undefined && previous.origin >= installed.origin) {
143
+ invalid('Installed releases must be origin-ordered and unique.');
144
+ }
145
+ }
146
+ return Object.freeze(releases);
147
+ }
148
+ function acceptInstalledRelease(input) {
149
+ const value = record(input, 'Installed release');
150
+ exact(value, ['inFlight', 'issuer', 'origin', 'pin', 'revision', 'url'], 'Installed release');
151
+ if (typeof value.origin !== 'string' || !patterns.origin.test(value.origin)) {
152
+ invalid('Installed release origin is invalid.');
153
+ }
154
+ if (typeof value.revision !== 'string' || !patterns.revision.test(value.revision)) {
155
+ invalid('Installed release revision is invalid.');
156
+ }
157
+ const issuer = issuerId(value.issuer, 'Installed release issuer');
158
+ return Object.freeze({
159
+ origin: value.origin,
160
+ revision: value.revision,
161
+ issuer,
162
+ url: deploymentUrl(value.url),
163
+ pin: installedPin(value.pin),
164
+ inFlight: inFlightIssuers(value.inFlight, issuer),
165
+ });
166
+ }
167
+ function installedPin(input) {
168
+ const value = record(input, 'Installed pin');
169
+ if (value.kind === 'release') {
170
+ exact(value, ['build', 'kind', 'release'], 'Installed release pin');
171
+ return Object.freeze({
172
+ kind: 'release',
173
+ release: digest(value.release, 'Installed release'),
174
+ build: digest(value.build, 'Installed build'),
175
+ });
176
+ }
177
+ // Deprecated legacy v2/v3 pin: deleted with the `legacy` variant of `InstalledPin`, in the
178
+ // breaking release that deletes `runtime/schema/installation/source/legacy/`.
179
+ if (value.kind === 'legacy') {
180
+ exact(value, ['document', 'etag', 'kind'], 'Installed legacy pin');
181
+ if (value.document !== 2 && value.document !== 3) {
182
+ invalid('Installed legacy pin document version is invalid.');
183
+ }
184
+ return Object.freeze({
185
+ kind: 'legacy',
186
+ document: value.document,
187
+ etag: digest(value.etag, 'Installed legacy Publication'),
188
+ });
189
+ }
190
+ return invalid('Installed pin kind is invalid.');
191
+ }
192
+ function inFlightIssuers(input, current) {
193
+ if (!Array.isArray(input))
194
+ invalid('Installed release in-flight issuers are invalid.');
195
+ const entries = input.map((entry) => {
196
+ const value = record(entry, 'In-flight issuer');
197
+ exact(value, ['issuer', 'release', 'replacedAt'], 'In-flight issuer');
198
+ if (typeof value.replacedAt !== 'string' || !isInstant(value.replacedAt)) {
199
+ invalid('In-flight issuer replacement instant is invalid.');
200
+ }
201
+ return Object.freeze({
202
+ issuer: issuerId(value.issuer, 'In-flight issuer'),
203
+ release: digest(value.release, 'In-flight release'),
204
+ replacedAt: value.replacedAt,
205
+ });
206
+ });
207
+ for (const [index, entry] of entries.entries()) {
208
+ if (entry.issuer === current)
209
+ invalid('An in-flight issuer cannot be the current issuer.');
210
+ const previous = entries[index - 1];
211
+ if (previous !== undefined && previous.issuer >= entry.issuer) {
212
+ invalid('In-flight issuers must be issuer-ordered and unique.');
213
+ }
214
+ }
215
+ return Object.freeze(entries);
216
+ }
217
+ function issuerId(input, label) {
218
+ if (!isIssuer(input))
219
+ invalid(`${label} is invalid.`);
220
+ return input;
221
+ }
222
+ /** An HTTP(S) origin, spelled exactly as `URL.origin` spells it. */
223
+ function deploymentUrl(input) {
224
+ let url;
225
+ try {
226
+ url = typeof input === 'string' ? new URL(input) : undefined;
227
+ }
228
+ catch {
229
+ url = undefined;
230
+ }
231
+ if (url === undefined || (url.protocol !== 'https:' && url.protocol !== 'http:')) {
232
+ invalid('Installed release URL is invalid.');
233
+ }
234
+ if (url.origin !== input)
235
+ invalid('Installed release URL must be an origin.');
236
+ return url.origin;
237
+ }
238
+ /** A UTC millisecond instant, spelled exactly as `Date.prototype.toISOString` spells it. */
239
+ function isInstant(input) {
240
+ const time = Date.parse(input);
241
+ return Number.isFinite(time) && new Date(time).toISOString() === input;
242
+ }
128
243
  function encodeDomainBundle(input) {
129
244
  exact(input, ['bundle', 'domain'], 'Domain Bundle result');
130
245
  const admittedDomain = acceptDomainInfo(input.domain);
@@ -238,6 +353,10 @@ function capabilities(input) {
238
353
  /** Package-private: admit one introspection request. Shared with `../legacy/`. */
239
354
  export function admitRequest(input) {
240
355
  const value = record(input, 'Schema introspection request');
356
+ if (value.kind === 'installed') {
357
+ exact(value, ['kind'], 'Schema introspection request');
358
+ return Object.freeze({ kind: 'installed' });
359
+ }
241
360
  exact(value, ['kind', 'origin'], 'Schema introspection request');
242
361
  if (value.kind !== 'inspect' && value.kind !== 'bundle' && value.kind !== 'dependencies') {
243
362
  invalid('Schema introspection kind is invalid.');
@@ -1,2 +1,2 @@
1
1
  export { MEDIA_TYPE, acceptDomainDependencies, acceptDomainInfo, decode, encode } from './codec.js';
2
- export type { DependencyBinding, Digest, DomainBundle, DomainDependencies, DomainInfo, Limits, ResultFor, } from './result.js';
2
+ export type { DependencyBinding, Digest, DomainBundle, DomainDependencies, DomainInfo, InFlightIssuer, InstalledPin, InstalledRelease, Limits, ResultFor, } from './result.js';
@@ -1,5 +1,7 @@
1
+ import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
1
2
  import type { introspection } from '@astrale-os/kernel-core/schema';
2
3
  import type { bundle, Key, Revision } from '@astrale-os/kernel-dsl/v1';
4
+ import type { BuildDigest, ReleaseDigest } from '../../../release/index.js';
3
5
  import type { Publication, PublicationRef, PublicationRequirements } from '../../../release/legacy/index.js';
4
6
  export type Digest = `sha256:${string}`;
5
7
  /** Lightweight proof of one exact ready installed Domain generation. */
@@ -43,7 +45,57 @@ export interface DomainDependencies {
43
45
  readonly domain: DomainInfo;
44
46
  readonly dependencies: readonly DependencyBinding[];
45
47
  }
48
+ /**
49
+ * What one installation pins, one meaning per variant: a `DomainRelease` v4 by its release digest
50
+ * beside the build digest it runs, or a legacy v2/v3 Publication by its strong ETag.
51
+ */
52
+ export type InstalledPin = ReleasePin | LegacyPin;
53
+ /** A `DomainRelease` v4 pin: its release digest beside the build digest it runs. */
54
+ interface ReleasePin {
55
+ readonly kind: 'release';
56
+ readonly release: ReleaseDigest;
57
+ readonly build: BuildDigest;
58
+ }
59
+ /**
60
+ * A legacy v2/v3 Publication pin: its document version and strong ETag.
61
+ *
62
+ * @deprecated Successor: the `release` pin. Short-term consumer: the installations that still pin
63
+ * a v2/v3 Publication, such as the astrale Host's v3 installs until Shell is republished as a
64
+ * release. Deleted, with its codec branch, in the separate breaking release that deletes
65
+ * `runtime/schema/installation/source/legacy/`, once no installation pins a v2/v3 Publication
66
+ * (the conditions of `protocol/release/legacy/index.ts`).
67
+ */
68
+ interface LegacyPin {
69
+ readonly kind: 'legacy';
70
+ readonly document: 2 | 3;
71
+ readonly etag: Digest;
72
+ }
73
+ /**
74
+ * The issuer of a release one installation replaced, as the Kernel records it: accepted while its
75
+ * work drains, and listed until a later consented install re-pins or prunes it, drained or not.
76
+ */
77
+ export interface InFlightIssuer {
78
+ readonly issuer: IssuerId;
79
+ /** Pinned document digest of the replaced release. */
80
+ readonly release: Digest;
81
+ /** UTC millisecond ISO 8601 instant of the activation commit that replaced it. */
82
+ readonly replacedAt: string;
83
+ }
84
+ /** One ready installation the Kernel pins to a deployment, as its caller can read it. */
85
+ export interface InstalledRelease {
86
+ readonly origin: Key.Origin;
87
+ readonly revision: Revision;
88
+ /** Issuer of the installation's Domain principal. */
89
+ readonly issuer: IssuerId;
90
+ /** The deployment URL, an HTTP(S) origin, the Kernel discovered the pinned document at. */
91
+ readonly url: string;
92
+ readonly pin: InstalledPin;
93
+ /** Issuer-ordered, distinct, never the current issuer. */
94
+ readonly inFlight: readonly InFlightIssuer[];
95
+ }
46
96
  export type ResultFor<Input extends introspection.Introspection> = Input extends {
97
+ readonly kind: 'installed';
98
+ } ? readonly InstalledRelease[] : Input extends {
47
99
  readonly kind: 'bundle';
48
100
  } ? DomainBundle : Input extends {
49
101
  readonly kind: 'dependencies';
@@ -52,3 +104,4 @@ export interface Limits {
52
104
  readonly maximumPublicationBytes: number;
53
105
  readonly maximumBundleBytes: number;
54
106
  }
107
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-protocol",
3
- "version": "0.5.0-beta.68",
3
+ "version": "0.5.0-beta.69",
4
4
  "description": "Portable Astrale invocation, publication, installation, routing, and issuer protocols",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -27,6 +27,10 @@
27
27
  "types": "./dist/errors/index.d.ts",
28
28
  "import": "./dist/errors/index.js"
29
29
  },
30
+ "./issuer/claims": {
31
+ "types": "./dist/issuer/claims.d.ts",
32
+ "import": "./dist/issuer/claims.js"
33
+ },
30
34
  "./issuer/configuration": {
31
35
  "types": "./dist/issuer/configuration.d.ts",
32
36
  "import": "./dist/issuer/configuration.js"
@@ -169,8 +173,8 @@
169
173
  "access": "public"
170
174
  },
171
175
  "dependencies": {
172
- "@astrale-os/kernel-core": "0.9.0-beta.62",
173
- "@astrale-os/kernel-dsl": "0.2.0-beta.44",
176
+ "@astrale-os/kernel-core": "0.9.0-beta.63",
177
+ "@astrale-os/kernel-dsl": "0.2.0-beta.45",
174
178
  "@noble/hashes": "2.3.0"
175
179
  },
176
180
  "devDependencies": {