@byok-sdk/cloud 0.1.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/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/auth/bearer.d.ts +25 -0
- package/dist/auth/device-proof.d.ts +38 -0
- package/dist/auth/plane.d.ts +67 -0
- package/dist/auth/tokens.d.ts +37 -0
- package/dist/auth/verify.d.ts +22 -0
- package/dist/board-projection.d.ts +9 -0
- package/dist/capabilities.d.ts +71 -0
- package/dist/cloud.d.ts +123 -0
- package/dist/composition/in-memory.d.ts +55 -0
- package/dist/coordination-client.d.ts +87 -0
- package/dist/coordination.d.ts +29 -0
- package/dist/crypto/port.d.ts +44 -0
- package/dist/crypto/web-crypto.d.ts +28 -0
- package/dist/errors.d.ts +45 -0
- package/dist/handlers/auth.d.ts +21 -0
- package/dist/handlers/blobs.d.ts +39 -0
- package/dist/handlers/board.d.ts +21 -0
- package/dist/handlers/capabilities.d.ts +14 -0
- package/dist/handlers/events.d.ts +33 -0
- package/dist/handlers/messages.d.ts +28 -0
- package/dist/handlers/presence.d.ts +13 -0
- package/dist/handlers/shared.d.ts +30 -0
- package/dist/handlers/truth.d.ts +15 -0
- package/dist/inbound.d.ts +35 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +2419 -0
- package/dist/index.js.map +1 -0
- package/dist/router/registry.d.ts +56 -0
- package/dist/stores/in-memory/blobs.d.ts +89 -0
- package/dist/stores/in-memory/dedup.d.ts +17 -0
- package/dist/stores/in-memory/device-directory.d.ts +19 -0
- package/dist/stores/in-memory/index.d.ts +36 -0
- package/dist/stores/in-memory/nonces.d.ts +22 -0
- package/dist/stores/in-memory/pairing-codes.d.ts +18 -0
- package/dist/stores/in-memory/proof-receipts.d.ts +11 -0
- package/dist/stores/in-memory/rate-limiter.d.ts +13 -0
- package/dist/stores/in-memory/receipts.d.ts +21 -0
- package/dist/stores/in-memory/sequence.d.ts +11 -0
- package/dist/stores/in-memory/task-attempts.d.ts +36 -0
- package/dist/stores/ports-contract.d.ts +20 -0
- package/dist/stores/ports.d.ts +309 -0
- package/dist/tenant-stores.d.ts +124 -0
- package/dist/truth/contract.d.ts +149 -0
- package/dist/truth/errors.d.ts +8 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ancienttwo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @byok-sdk/cloud
|
|
2
|
+
|
|
3
|
+
Stateless hosted BYOK HTTP handlers and an in-memory reference composition over
|
|
4
|
+
tenant-first `@byok-sdk/core` ports. It owns device-facing protocol/auth/policy
|
|
5
|
+
logic but no durable database or object-storage driver.
|
|
6
|
+
|
|
7
|
+
Pair it with `@byok-sdk/cloud-postgres` for Postgres + R2 production storage.
|
|
8
|
+
|
|
9
|
+
MIT licensed. Node.js 20 or newer.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single bearer check every device-facing route shares.
|
|
3
|
+
*
|
|
4
|
+
* S1 shape, reproduced exactly: the token's `(tenantId, deviceId, productId)`
|
|
5
|
+
* are LOOKUP KEYS, and the ROW that comes back is the authority. A token for a
|
|
6
|
+
* device that no longer exists, one whose tenant does not own that device, one
|
|
7
|
+
* whose product disagrees with the row, one for a revoked device, an expired
|
|
8
|
+
* token, and a malformed tenant id in the claims all fail identically here and
|
|
9
|
+
* are indistinguishable to the caller. There is deliberately no "which of
|
|
10
|
+
* those was it" signal to hand back, so no route can turn a 401 into a
|
|
11
|
+
* cross-tenant existence oracle.
|
|
12
|
+
*
|
|
13
|
+
* The returned {@link DevicePrincipal} is core's, carrying a minted
|
|
14
|
+
* `TenantId` — which is what makes it the only legal input to
|
|
15
|
+
* `tenantStoresFor` (`../tenant-stores.ts`).
|
|
16
|
+
*/
|
|
17
|
+
import { type DevicePrincipal } from '@byok-sdk/core';
|
|
18
|
+
import type { DeviceDirectory } from '../stores/ports';
|
|
19
|
+
import type { TokenSigner } from './tokens';
|
|
20
|
+
export interface BearerAuthDeps {
|
|
21
|
+
readonly tokenSigner: TokenSigner;
|
|
22
|
+
readonly devices: DeviceDirectory;
|
|
23
|
+
}
|
|
24
|
+
export declare function extractBearerToken(header: string | undefined): string | undefined;
|
|
25
|
+
export declare function authenticateBearer(header: string | undefined, deps: BearerAuthDeps): Promise<DevicePrincipal | undefined>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Clock, type DevicePrincipal } from '@byok-sdk/core';
|
|
2
|
+
import type { CloudCrypto } from '../crypto/port';
|
|
3
|
+
import type { DeviceDirectory } from '../stores/ports';
|
|
4
|
+
export declare const DEFAULT_DEVICE_PROOF_CLOCK_SKEW_MS = 60000;
|
|
5
|
+
export declare const MAX_DEVICE_PROOF_CLOCK_SKEW_MS: number;
|
|
6
|
+
export declare const DEFAULT_DEVICE_PROOF_MAX_LIFETIME_MS: number;
|
|
7
|
+
export declare const MAX_DEVICE_PROOF_MAX_LIFETIME_MS: number;
|
|
8
|
+
export interface DeviceProofRequestBinding {
|
|
9
|
+
readonly method: string;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly operation: string;
|
|
12
|
+
readonly resource: string;
|
|
13
|
+
readonly body: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
export interface DeviceProofAuthDeps {
|
|
16
|
+
readonly devices: DeviceDirectory;
|
|
17
|
+
readonly crypto: CloudCrypto;
|
|
18
|
+
readonly clock: Clock;
|
|
19
|
+
readonly clockSkewMs?: number;
|
|
20
|
+
readonly maxLifetimeMs?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface AuthenticatedDeviceProof {
|
|
23
|
+
/** Row-derived principal; no protected claim is re-used as authority. */
|
|
24
|
+
readonly device: DevicePrincipal;
|
|
25
|
+
readonly requestId: string;
|
|
26
|
+
readonly operation: string;
|
|
27
|
+
readonly resource: string;
|
|
28
|
+
readonly bodySha256: string;
|
|
29
|
+
readonly bodySize: bigint;
|
|
30
|
+
readonly keyId: string;
|
|
31
|
+
readonly keyEpoch: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Authenticate a request-bound proof. Every rejected state is `undefined`, so
|
|
35
|
+
* a route has one 401 response for malformed input, row misses, revocation,
|
|
36
|
+
* stale epochs, binding changes, clock failures and bad signatures.
|
|
37
|
+
*/
|
|
38
|
+
export declare function authenticateDeviceProof(input: unknown, request: DeviceProofRequestBinding, deps: DeviceProofAuthDeps): Promise<AuthenticatedDeviceProof | undefined>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auth plane: everything the three pre-tenant routes (`/byok/pair`,
|
|
3
|
+
* `/byok/challenge`, `/byok/token`) need, and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Those three routes cannot take a {@link TenantStores} facade — they run
|
|
6
|
+
* BEFORE a principal exists, which is exactly why they are the surface where a
|
|
7
|
+
* tenant boundary is easiest to lose. So instead of handing them naked stores
|
|
8
|
+
* and trusting each handler to pass the right tenant, this module owns every
|
|
9
|
+
* tenant-carrying call they make and hands back operations that name no tenant
|
|
10
|
+
* at all:
|
|
11
|
+
*
|
|
12
|
+
* - a pairing code redeems into a REGISTERED device row in one step, so the
|
|
13
|
+
* claims never sit in handler scope as a tenant a handler could substitute;
|
|
14
|
+
* - a device resolves to a row (the pre-tenant lookup, §6.2's pinned wire
|
|
15
|
+
* contract), and unknown/revoked collapse to one answer;
|
|
16
|
+
* - nonce issue/validate/consume and token minting take that ROW, so the
|
|
17
|
+
* tenant they act on is the row's, by construction.
|
|
18
|
+
*/
|
|
19
|
+
import type { Clock, TenantId } from '@byok-sdk/core';
|
|
20
|
+
import type { CloudCrypto } from '../crypto/port';
|
|
21
|
+
import type { CloudStores, DeviceRecord, PairingCodeInfo } from '../stores/ports';
|
|
22
|
+
import { type TokenSigner } from './tokens';
|
|
23
|
+
/** ~10min, matching the reference server's pairing-code lifetime. */
|
|
24
|
+
export declare const PAIRING_CODE_TTL_MS: number;
|
|
25
|
+
/** The paired identity key is the first and only proof key in S6. */
|
|
26
|
+
export declare const DEVICE_IDENTITY_PROOF_KEY_ID = "identity";
|
|
27
|
+
export declare const DEVICE_IDENTITY_PROOF_KEY_EPOCH = 0;
|
|
28
|
+
export interface PairInput {
|
|
29
|
+
readonly pairingCode: string;
|
|
30
|
+
readonly deviceName: string;
|
|
31
|
+
readonly devicePublicKey: string;
|
|
32
|
+
}
|
|
33
|
+
export interface MintedAccessToken {
|
|
34
|
+
readonly accessToken: string;
|
|
35
|
+
readonly expiresAt: string;
|
|
36
|
+
}
|
|
37
|
+
export interface AuthPlane {
|
|
38
|
+
/** Host control plane: mint a single-use code carrying the tenant/product a device will land in. */
|
|
39
|
+
createPairingCode(tenant: TenantId, input: {
|
|
40
|
+
readonly productId: string;
|
|
41
|
+
readonly ttlMs?: number;
|
|
42
|
+
}): Promise<PairingCodeInfo>;
|
|
43
|
+
/**
|
|
44
|
+
* Redeem a code and register the device it pairs, in one step. `undefined`
|
|
45
|
+
* for a code that is unknown, expired, or already used — one answer for all
|
|
46
|
+
* three. Single-use redemption is what makes this exclusive: a second
|
|
47
|
+
* redeem never reaches the row write.
|
|
48
|
+
*/
|
|
49
|
+
redeemAndRegister(input: PairInput): Promise<DeviceRecord | undefined>;
|
|
50
|
+
/** The pre-tenant device lookup §6.2's wire contract forces. `undefined` for unknown AND revoked alike — no existence oracle. */
|
|
51
|
+
resolveDevice(deviceId: string): Promise<DeviceRecord | undefined>;
|
|
52
|
+
issueNonce(device: DeviceRecord): Promise<string>;
|
|
53
|
+
validateNonce(device: DeviceRecord, nonce: string): Promise<boolean>;
|
|
54
|
+
/** Burn the nonce. Called only on a fully verified request (§6.2). */
|
|
55
|
+
consumeNonce(device: DeviceRecord, nonce: string): Promise<void>;
|
|
56
|
+
/** Domain-separated (`byok-nonce-v1\n`) — see `verify.ts`. A raw signature over the bare nonce is invalid, with no second accepted form. */
|
|
57
|
+
verifySignature(device: DeviceRecord, nonce: string, signature: string): Promise<boolean>;
|
|
58
|
+
mintAccessToken(device: DeviceRecord): Promise<MintedAccessToken>;
|
|
59
|
+
}
|
|
60
|
+
export interface AuthPlaneDeps {
|
|
61
|
+
readonly stores: CloudStores;
|
|
62
|
+
readonly crypto: CloudCrypto;
|
|
63
|
+
readonly clock: Clock;
|
|
64
|
+
readonly tokenSigner: TokenSigner;
|
|
65
|
+
readonly accessTokenTtlSeconds?: number;
|
|
66
|
+
}
|
|
67
|
+
export declare function createAuthPlane(deps: AuthPlaneDeps): AuthPlane;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access tokens for the hosted device surface (docs/protocol.md §6).
|
|
3
|
+
*
|
|
4
|
+
* The token carries the identity TRIPLE — `(deviceId, tenantId, productId)` —
|
|
5
|
+
* and all three legs are lookup keys, never authority: `authenticateBearer`
|
|
6
|
+
* (`bearer.ts`) resolves them against the device directory and answers with
|
|
7
|
+
* the ROW's identity. A token missing a leg is not a partially usable token;
|
|
8
|
+
* it is not a token this deployment minted.
|
|
9
|
+
*
|
|
10
|
+
* {@link TokenSigner} is a port for the same reason `@byok-sdk/server` made it
|
|
11
|
+
* one: a SaaS with an org-wide asymmetric signer or a KMS swaps its own in.
|
|
12
|
+
* The reference implementation below is HS256 over a caller-supplied secret,
|
|
13
|
+
* built on WebCrypto so the package stays runtime-neutral (no `jose`, no
|
|
14
|
+
* `node:crypto`).
|
|
15
|
+
*/
|
|
16
|
+
import type { Clock } from '@byok-sdk/core';
|
|
17
|
+
/** Access tokens are ~1h, matching the reference server (docs/protocol.md §6.1/§6.2). */
|
|
18
|
+
export declare const ACCESS_TOKEN_TTL_SECONDS: number;
|
|
19
|
+
export interface AccessTokenClaims {
|
|
20
|
+
readonly deviceId: string;
|
|
21
|
+
/** Unbranded on the wire: the value is only a lookup key until a device row vouches for it. */
|
|
22
|
+
readonly tenantId: string;
|
|
23
|
+
readonly productId: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TokenSigner {
|
|
26
|
+
sign(claims: AccessTokenClaims, expiresInSeconds: number): Promise<string>;
|
|
27
|
+
/** Claims for a valid, unexpired token, or `undefined` for invalid/expired/malformed — no reason is ever reported. */
|
|
28
|
+
verify(token: string): Promise<AccessTokenClaims | undefined>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* HS256 JWT signer over `secret`.
|
|
32
|
+
*
|
|
33
|
+
* `clock` is injected rather than read off `Date.now()` so expiry is testable
|
|
34
|
+
* and so the whole package keeps a single notion of "now" (the same one the
|
|
35
|
+
* stores use for TTLs).
|
|
36
|
+
*/
|
|
37
|
+
export declare function createHmacTokenSigner(secret: Uint8Array, clock: Clock): TokenSigner;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one nonce-signature check on the hosted surface (docs/protocol.md §6.2).
|
|
3
|
+
*
|
|
4
|
+
* S1 (GAP-004): a device signs `byok-nonce-v1\n` + nonce, never the bare
|
|
5
|
+
* nonce. The device key is a long-lived identity key that later planes (S6
|
|
6
|
+
* device proof) also sign structured messages with; without a domain tag, a
|
|
7
|
+
* signature produced for one purpose is a valid signature for another.
|
|
8
|
+
*
|
|
9
|
+
* Applying the domain HERE rather than at the call site is the point: there
|
|
10
|
+
* is exactly one place that decides what a device signature over a nonce
|
|
11
|
+
* means, so no route can be written that accepts the undomained form. There
|
|
12
|
+
* is deliberately no dual mode and no grace window — a device on the old
|
|
13
|
+
* encoding re-pairs.
|
|
14
|
+
*
|
|
15
|
+
* The literal is byte-identical to `@byok-sdk/server`'s `NONCE_SIGNING_DOMAIN`
|
|
16
|
+
* and to what the daemon signs (`packages/client/src/daemon/device-keys.ts`),
|
|
17
|
+
* because the daemon must not be able to tell self-hosted from hosted. Parity
|
|
18
|
+
* is asserted by behavior tests, never by importing the server.
|
|
19
|
+
*/
|
|
20
|
+
import type { CloudCrypto } from '../crypto/port';
|
|
21
|
+
export declare const NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
|
|
22
|
+
export declare function verifyNonceSignature(crypto: CloudCrypto, devicePublicKey: string, nonce: string, signature: string): Promise<boolean>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TenantBoundBoard } from './tenant-stores';
|
|
2
|
+
/**
|
|
3
|
+
* One-way projection from an already accepted terminal fact into coordination.
|
|
4
|
+
*
|
|
5
|
+
* This module deliberately knows no wire message type or execution-state
|
|
6
|
+
* vocabulary. It can only CAS an existing coordination row one step toward
|
|
7
|
+
* review, and a concurrent board decision wins without retry or overwrite.
|
|
8
|
+
*/
|
|
9
|
+
export declare function projectTerminalToReview(board: TenantBoundBoard, taskId: string): Promise<void>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hosted capability declaration (ADR-010).
|
|
3
|
+
*
|
|
4
|
+
* A client learns what a deployment supports by READING a declaration, never
|
|
5
|
+
* by probing an endpoint and interpreting 404/405/501. `@byok-sdk/core` owns the
|
|
6
|
+
* declaration shape and the `hasCapability`/`assertCapability` enforcement
|
|
7
|
+
* point; what lives here is the hosted vocabulary and the mapping from a
|
|
8
|
+
* capability name to the routes that provide it.
|
|
9
|
+
*
|
|
10
|
+
* `GET /byok/capabilities` is a hosted-only route: it is not part of the
|
|
11
|
+
* frozen device wire contract, `@byok-sdk/protocol` is untouched by it, and the
|
|
12
|
+
* daemon does not consume it yet (that lands in a later slice). What it
|
|
13
|
+
* already does here is drive route selection — a deployment that does not
|
|
14
|
+
* declare `blobs.presigned` does not mount the grant routes at all, and one
|
|
15
|
+
* that does not declare `blobs.contentproxy` does not mount the two `/content`
|
|
16
|
+
* routes, so the declaration and the surface cannot disagree.
|
|
17
|
+
*/
|
|
18
|
+
import { type CapabilityDeclaration } from '@byok-sdk/core';
|
|
19
|
+
/** The hosted capability vocabulary this package knows how to serve. */
|
|
20
|
+
export declare const CLOUD_CAPABILITIES: {
|
|
21
|
+
/** `GET /byok/events` long-poll receive (§8). */
|
|
22
|
+
readonly eventsLongPoll: 'events.longpoll';
|
|
23
|
+
/** `POST /byok/messages` batched send (§8.2). */
|
|
24
|
+
readonly messagesBatch: 'messages.batch';
|
|
25
|
+
/** The three bearer-authed blob routes: reserve/grant, explicit finalize, committed-only download (§7). */
|
|
26
|
+
readonly blobsPresigned: 'blobs.presigned';
|
|
27
|
+
/**
|
|
28
|
+
* The two presigned `/byok/blobs/:id/content` routes — cloud carrying the
|
|
29
|
+
* bytes itself.
|
|
30
|
+
*
|
|
31
|
+
* Split out of `blobs.presigned` because it was one capability describing two
|
|
32
|
+
* separable facts. A composition whose bytes live in object storage mints
|
|
33
|
+
* grants (`blobs.presigned`) but has no byte-proxy path at all, and saying so
|
|
34
|
+
* by declaration is ADR-010's whole posture: a client reads what a deployment
|
|
35
|
+
* serves, it never probes a `/content` route and interprets the status code.
|
|
36
|
+
*
|
|
37
|
+
* Spelled all-lowercase because core's declaration schema pins capability
|
|
38
|
+
* names to `/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/` — the same reason the sibling
|
|
39
|
+
* above reads `events.longpoll` and not `events.longPoll`.
|
|
40
|
+
*/
|
|
41
|
+
readonly blobsContentProxy: 'blobs.contentproxy';
|
|
42
|
+
/** Board list/claim/unclaim/status routes. Polling is first-class under this declaration. */
|
|
43
|
+
readonly boardCoordination: 'board.coordination';
|
|
44
|
+
/** Additional SSE transport for the same board read model. */
|
|
45
|
+
readonly boardSse: 'board.sse';
|
|
46
|
+
/** Device-scoped five-level presence publication. */
|
|
47
|
+
readonly presenceHints: 'presence.hints';
|
|
48
|
+
/** Bounded task activity batch publication. */
|
|
49
|
+
readonly activityTail: 'activity.tail';
|
|
50
|
+
/** Request-bound device proof record manifest/read/write surface (S6). */
|
|
51
|
+
readonly truthRecords: 'truth.records';
|
|
52
|
+
};
|
|
53
|
+
export type CloudCapability = (typeof CLOUD_CAPABILITIES)[keyof typeof CLOUD_CAPABILITIES];
|
|
54
|
+
/** The wire DTO for `GET /byok/capabilities` — core's shape, bound to a cloud-owned route. */
|
|
55
|
+
export declare const CapabilitiesResponseSchema: import("zod").ZodObject<{
|
|
56
|
+
schema: import("zod").ZodLiteral<"byok-capabilities-v1">;
|
|
57
|
+
version: import("zod").ZodNumber;
|
|
58
|
+
capabilities: import("zod").ZodArray<import("zod").ZodString>;
|
|
59
|
+
}, import("zod/v4/core").$strip>;
|
|
60
|
+
export type CapabilitiesResponse = CapabilityDeclaration;
|
|
61
|
+
export interface FullCapabilityDeclarationOptions {
|
|
62
|
+
/**
|
|
63
|
+
* Composition-bound capabilities require an explicit application authority.
|
|
64
|
+
* `truth.records` is omitted by default because the standard in-memory
|
|
65
|
+
* composition cannot truthfully promise a cross-store atomic commit.
|
|
66
|
+
*/
|
|
67
|
+
readonly includeTruthRecords?: boolean;
|
|
68
|
+
}
|
|
69
|
+
/** Every capability the standard composition can serve, plus explicitly wired composition-bound ones. */
|
|
70
|
+
export declare function fullCapabilityDeclaration(version?: number, options?: FullCapabilityDeclarationOptions): CapabilityDeclaration;
|
|
71
|
+
export declare function declares(declaration: CapabilityDeclaration, capability: CloudCapability): boolean;
|
package/dist/cloud.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createByokCloud` — the hosted device surface, assembled.
|
|
3
|
+
*
|
|
4
|
+
* What this is NOT: an embedded coordinator. There is no `TaskHandle`, no
|
|
5
|
+
* connection registry, no per-task object that a host holds onto. Sprint 0.1's
|
|
6
|
+
* non-goal is explicit about that — the embedded `TaskHandle` must not become
|
|
7
|
+
* the hosted API — so the host's control-plane input is a function
|
|
8
|
+
* ({@link ByokCloud.enqueueOffer}) and its read model is a store query
|
|
9
|
+
* ({@link ByokCloud.readTaskAttempt}).
|
|
10
|
+
*
|
|
11
|
+
* Every route is stateless in the strict sense: it resolves a principal from
|
|
12
|
+
* the request, binds stores to that principal's tenant, and returns. Nothing
|
|
13
|
+
* survives the response but what a store wrote. The one shape that would
|
|
14
|
+
* quietly undo that — a Running/session map — is asserted absent by
|
|
15
|
+
* `src/__tests__/constraints.test.ts`.
|
|
16
|
+
*/
|
|
17
|
+
import { type ActivityTail, type BoardItem, type BoardItemInput, type BoardListQuery, type BoardPage, type CapabilityDeclaration, type Clock, type CoreStores, type PresenceHint, type TenantId } from '@byok-sdk/core';
|
|
18
|
+
import { type Envelope, type TaskOfferPayload } from '@byok-sdk/protocol';
|
|
19
|
+
import type { TokenSigner } from './auth/tokens';
|
|
20
|
+
import type { CloudCrypto } from './crypto/port';
|
|
21
|
+
import { type RouteDescriptor } from './router/registry';
|
|
22
|
+
import type { BlobContentProxy, CloudStores, DeviceRecord, PairingCodeInfo, RequestReceipt, TaskAttempt } from './stores/ports';
|
|
23
|
+
import type { TruthCommitter, TruthObjectDownloads } from './truth/contract';
|
|
24
|
+
/** Matches the reference server's ceiling (§7). */
|
|
25
|
+
export declare const DEFAULT_MAX_BLOB_SIZE_BYTES: number;
|
|
26
|
+
/** Matches the reference server's hold (§8). */
|
|
27
|
+
export declare const DEFAULT_LONG_POLL_HOLD_MS = 50000;
|
|
28
|
+
/** How often a held poll re-reads the mailbox. */
|
|
29
|
+
export declare const DEFAULT_LONG_POLL_INTERVAL_MS = 250;
|
|
30
|
+
/** Rows per `GET /byok/events` response. */
|
|
31
|
+
export declare const DEFAULT_EVENTS_PAGE_LIMIT = 50;
|
|
32
|
+
export interface ByokCloudOptions {
|
|
33
|
+
readonly core: CoreStores;
|
|
34
|
+
readonly cloud: CloudStores;
|
|
35
|
+
/**
|
|
36
|
+
* Byte proxying for the two `/byok/blobs/:id/content` routes. OPTIONAL, and
|
|
37
|
+
* absent is a first-class answer: a composition backed by object storage
|
|
38
|
+
* hands the device a presigned URL to the object store and never carries a
|
|
39
|
+
* byte, so it has nothing to supply here. Supplying it is necessary but not
|
|
40
|
+
* sufficient for the routes to exist — the deployment must also declare
|
|
41
|
+
* `blobs.contentproxy` (ADR-010).
|
|
42
|
+
*/
|
|
43
|
+
readonly blobContentProxy?: BlobContentProxy;
|
|
44
|
+
/** Atomic proof receipt + truth/reference/accounting authority for S6 routes. */
|
|
45
|
+
readonly truthCommitter?: TruthCommitter;
|
|
46
|
+
/** Content-hash keyed object GET grants for object-backed truth bodies. */
|
|
47
|
+
readonly truthObjectDownloads?: TruthObjectDownloads;
|
|
48
|
+
readonly crypto: CloudCrypto;
|
|
49
|
+
readonly tokenSigner: TokenSigner;
|
|
50
|
+
readonly clock: Clock;
|
|
51
|
+
/** What this deployment serves (ADR-010). Routes are mounted from it, so it can never over-declare. */
|
|
52
|
+
readonly capabilities: CapabilityDeclaration;
|
|
53
|
+
/** Recorded on the control-plane principal every host-side call is made under. */
|
|
54
|
+
readonly operatorId?: string;
|
|
55
|
+
readonly maxBlobSizeBytes?: number;
|
|
56
|
+
readonly longPollHoldMs?: number;
|
|
57
|
+
readonly longPollIntervalMs?: number;
|
|
58
|
+
readonly eventsPageLimit?: number;
|
|
59
|
+
readonly accessTokenTtlSeconds?: number;
|
|
60
|
+
readonly boardPageLimit?: number;
|
|
61
|
+
readonly boardStreamQueryIntervalMs?: number;
|
|
62
|
+
readonly boardStreamHeartbeatIntervalMs?: number;
|
|
63
|
+
readonly boardStreamReconciliationIntervalMs?: number;
|
|
64
|
+
readonly boardChannelMaxBytes?: number;
|
|
65
|
+
readonly boardTitleMaxBytes?: number;
|
|
66
|
+
readonly presenceTtlMs?: number;
|
|
67
|
+
readonly presenceMinimumIntervalMs?: number;
|
|
68
|
+
readonly presenceDetailMaxBytes?: number;
|
|
69
|
+
readonly activityMaxEvents?: number;
|
|
70
|
+
readonly activityMaxBytes?: number;
|
|
71
|
+
readonly activityCapacity?: number;
|
|
72
|
+
readonly activityTtlMs?: number;
|
|
73
|
+
readonly maxTruthRequestBytes?: number;
|
|
74
|
+
}
|
|
75
|
+
export interface EnqueueOfferInput {
|
|
76
|
+
/** Supply one to make the enqueue addressable by the host's own id; otherwise cloud mints one. */
|
|
77
|
+
readonly taskId?: string;
|
|
78
|
+
readonly payload: TaskOfferPayload;
|
|
79
|
+
}
|
|
80
|
+
export interface EnqueuedOffer {
|
|
81
|
+
readonly taskId: string;
|
|
82
|
+
/** The per-(tenant, device) delivery seq — the daemon's redelivery cursor position for this envelope. */
|
|
83
|
+
readonly seq: number;
|
|
84
|
+
readonly envelope: Envelope;
|
|
85
|
+
readonly attempt: TaskAttempt;
|
|
86
|
+
}
|
|
87
|
+
export interface ByokCloud {
|
|
88
|
+
/** WHATWG fetch handler — mount on `@hono/node-server`, a Worker, or Deno. */
|
|
89
|
+
readonly fetch: (request: Request, ...rest: unknown[]) => Response | Promise<Response>;
|
|
90
|
+
/** The I1 route inventory: every mounted route and the credential class it requires. */
|
|
91
|
+
readonly routes: readonly RouteDescriptor[];
|
|
92
|
+
/**
|
|
93
|
+
* What the router ACTUALLY holds, read back off the router itself. Exposed
|
|
94
|
+
* so the I1 matrix can close in both directions: an inventory is only
|
|
95
|
+
* evidence if a route mounted some other way would show up here without a
|
|
96
|
+
* matching entry in {@link routes}.
|
|
97
|
+
*/
|
|
98
|
+
readonly mountedRoutes: readonly {
|
|
99
|
+
readonly method: string;
|
|
100
|
+
readonly path: string;
|
|
101
|
+
}[];
|
|
102
|
+
readonly capabilities: CapabilityDeclaration;
|
|
103
|
+
/** Host control plane: mint a single-use pairing code for a tenant/product. */
|
|
104
|
+
createPairingCode(tenant: TenantId, input: {
|
|
105
|
+
readonly productId: string;
|
|
106
|
+
readonly ttlMs?: number;
|
|
107
|
+
}): Promise<PairingCodeInfo>;
|
|
108
|
+
/** Host control plane: hand a device a frozen-v1 `task.offer`. The hosted replacement for `dispatch()` — a function, not a handle. */
|
|
109
|
+
enqueueOffer(tenant: TenantId, deviceId: string, input: EnqueueOfferInput): Promise<EnqueuedOffer>;
|
|
110
|
+
readTaskAttempt(tenant: TenantId, taskId: string): Promise<TaskAttempt | undefined>;
|
|
111
|
+
/** The recorded terminal for a task — the first one, re-encoded canonically under the frozen v1 codec (see `recordTerminal`, `inbound.ts`: the stored body is `encodeEnvelope` of the zod-parsed envelope, not the device's original byte sequence). */
|
|
112
|
+
readTerminalReceipt(tenant: TenantId, taskId: string): Promise<RequestReceipt | undefined>;
|
|
113
|
+
listDevices(tenant: TenantId): Promise<readonly DeviceRecord[]>;
|
|
114
|
+
revokeDevice(tenant: TenantId, deviceId: string): Promise<void>;
|
|
115
|
+
/** Host control plane: create a board row from explicit producer labels. */
|
|
116
|
+
createBoardItem(tenant: TenantId, input: BoardItemInput): Promise<BoardItem>;
|
|
117
|
+
listBoardItems(tenant: TenantId, query: BoardListQuery): Promise<BoardPage>;
|
|
118
|
+
/** Human review authority. Device routes cannot produce `done`. */
|
|
119
|
+
acceptBoardItem(tenant: TenantId, itemId: string): Promise<BoardItem>;
|
|
120
|
+
listPresence(tenant: TenantId): Promise<readonly PresenceHint[]>;
|
|
121
|
+
readActivity(tenant: TenantId, taskId: string): Promise<ActivityTail | undefined>;
|
|
122
|
+
}
|
|
123
|
+
export declare function createByokCloud(options: ByokCloudOptions): ByokCloud;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory composition: core's reference stores plus cloud's, wired into
|
|
3
|
+
* a working {@link ByokCloud}.
|
|
4
|
+
*
|
|
5
|
+
* This is what the handler suites and the client-side end-to-end test run
|
|
6
|
+
* against, and it is the honest demonstration of the S3 claim — the daemon
|
|
7
|
+
* cannot tell this from `@byok-sdk/server`, and nothing in the path needs a
|
|
8
|
+
* database to prove it.
|
|
9
|
+
*/
|
|
10
|
+
import { type Clock, type CapabilityDeclaration, type CoreStores } from '@byok-sdk/core';
|
|
11
|
+
import { type TokenSigner } from '../auth/tokens';
|
|
12
|
+
import type { CloudCrypto } from '../crypto/port';
|
|
13
|
+
import { type ByokCloud } from '../cloud';
|
|
14
|
+
import type { BlobContentProxy, CloudStores } from '../stores/ports';
|
|
15
|
+
import type { TruthCommitter, TruthObjectDownloads } from '../truth/contract';
|
|
16
|
+
export interface InMemoryByokCloudOptions {
|
|
17
|
+
readonly clock?: Clock;
|
|
18
|
+
readonly crypto?: CloudCrypto;
|
|
19
|
+
readonly tokenSigner?: TokenSigner;
|
|
20
|
+
readonly capabilities?: CapabilityDeclaration;
|
|
21
|
+
readonly operatorId?: string;
|
|
22
|
+
readonly maxBlobSizeBytes?: number;
|
|
23
|
+
readonly longPollHoldMs?: number;
|
|
24
|
+
readonly longPollIntervalMs?: number;
|
|
25
|
+
readonly eventsPageLimit?: number;
|
|
26
|
+
readonly accessTokenTtlSeconds?: number;
|
|
27
|
+
readonly boardPageLimit?: number;
|
|
28
|
+
readonly boardStreamQueryIntervalMs?: number;
|
|
29
|
+
readonly boardStreamHeartbeatIntervalMs?: number;
|
|
30
|
+
readonly boardStreamReconciliationIntervalMs?: number;
|
|
31
|
+
readonly boardChannelMaxBytes?: number;
|
|
32
|
+
readonly boardTitleMaxBytes?: number;
|
|
33
|
+
readonly presenceTtlMs?: number;
|
|
34
|
+
readonly presenceMinimumIntervalMs?: number;
|
|
35
|
+
readonly presenceDetailMaxBytes?: number;
|
|
36
|
+
readonly activityMaxEvents?: number;
|
|
37
|
+
readonly activityMaxBytes?: number;
|
|
38
|
+
readonly activityCapacity?: number;
|
|
39
|
+
readonly activityTtlMs?: number;
|
|
40
|
+
/** Test/host supplied atomic truth authority; never synthesized from sequential stores. */
|
|
41
|
+
readonly truthCommitter?: TruthCommitter;
|
|
42
|
+
readonly truthObjectDownloads?: TruthObjectDownloads;
|
|
43
|
+
readonly maxTruthRequestBytes?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface InMemoryByokCloud {
|
|
46
|
+
readonly cloud: ByokCloud;
|
|
47
|
+
/** The naked stores, for a host that wants to inspect or seed state directly. */
|
|
48
|
+
readonly core: CoreStores;
|
|
49
|
+
readonly stores: CloudStores;
|
|
50
|
+
/** The byte proxy the two `/content` routes were mounted on. */
|
|
51
|
+
readonly blobContentProxy: BlobContentProxy;
|
|
52
|
+
readonly clock: Clock;
|
|
53
|
+
readonly crypto: CloudCrypto;
|
|
54
|
+
}
|
|
55
|
+
export declare function createInMemoryByokCloud(options?: InMemoryByokCloudOptions): InMemoryByokCloud;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type CapabilityDeclaration } from '@byok-sdk/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
declare const BoardFeedItemSchema: z.ZodObject<{
|
|
4
|
+
tenantId: z.ZodString;
|
|
5
|
+
itemId: z.ZodString;
|
|
6
|
+
channel: z.ZodString;
|
|
7
|
+
title: z.ZodString;
|
|
8
|
+
status: z.ZodEnum<{
|
|
9
|
+
closed: "closed";
|
|
10
|
+
done: "done";
|
|
11
|
+
in_progress: "in_progress";
|
|
12
|
+
in_review: "in_review";
|
|
13
|
+
todo: "todo";
|
|
14
|
+
}>;
|
|
15
|
+
assignee: z.ZodOptional<z.ZodObject<{
|
|
16
|
+
holderId: z.ZodString;
|
|
17
|
+
heldSince: z.ZodString;
|
|
18
|
+
}, z.core.$strip>>;
|
|
19
|
+
boardSeq: z.ZodNumber;
|
|
20
|
+
createdAt: z.ZodString;
|
|
21
|
+
updatedAt: z.ZodString;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
declare const BoardFeedPageSchema: z.ZodObject<{
|
|
24
|
+
items: z.ZodArray<z.ZodObject<{
|
|
25
|
+
tenantId: z.ZodString;
|
|
26
|
+
itemId: z.ZodString;
|
|
27
|
+
channel: z.ZodString;
|
|
28
|
+
title: z.ZodString;
|
|
29
|
+
status: z.ZodEnum<{
|
|
30
|
+
closed: "closed";
|
|
31
|
+
done: "done";
|
|
32
|
+
in_progress: "in_progress";
|
|
33
|
+
in_review: "in_review";
|
|
34
|
+
todo: "todo";
|
|
35
|
+
}>;
|
|
36
|
+
assignee: z.ZodOptional<z.ZodObject<{
|
|
37
|
+
holderId: z.ZodString;
|
|
38
|
+
heldSince: z.ZodString;
|
|
39
|
+
}, z.core.$strip>>;
|
|
40
|
+
boardSeq: z.ZodNumber;
|
|
41
|
+
createdAt: z.ZodString;
|
|
42
|
+
updatedAt: z.ZodString;
|
|
43
|
+
}, z.core.$strip>>;
|
|
44
|
+
nextSeq: z.ZodNumber;
|
|
45
|
+
hasMore: z.ZodBoolean;
|
|
46
|
+
}, z.core.$strip>;
|
|
47
|
+
export type BoardFeedItem = z.infer<typeof BoardFeedItemSchema>;
|
|
48
|
+
export type BoardFeedPage = z.infer<typeof BoardFeedPageSchema>;
|
|
49
|
+
export type BoardFeedMode = 'sse' | 'poll';
|
|
50
|
+
export type BoardFeedRead = {
|
|
51
|
+
readonly type: 'board';
|
|
52
|
+
readonly item: BoardFeedItem;
|
|
53
|
+
} | {
|
|
54
|
+
readonly type: 'reconcile';
|
|
55
|
+
} | {
|
|
56
|
+
readonly type: 'poll';
|
|
57
|
+
readonly page: BoardFeedPage;
|
|
58
|
+
};
|
|
59
|
+
export declare class BoardFeedRetryableError extends Error {
|
|
60
|
+
constructor(message: string, options?: ErrorOptions);
|
|
61
|
+
}
|
|
62
|
+
export declare class BoardFeedStoppedError extends Error {
|
|
63
|
+
constructor(message: string);
|
|
64
|
+
}
|
|
65
|
+
export interface BoardFeedClientOptions {
|
|
66
|
+
readonly baseUrl: string | URL;
|
|
67
|
+
readonly accessToken: string;
|
|
68
|
+
readonly capabilities: CapabilityDeclaration;
|
|
69
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
70
|
+
readonly idleWatchdogMs?: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* One board feed consumer with a declaration-fixed transport.
|
|
74
|
+
*
|
|
75
|
+
* The mode is selected once from ADR-010 data. A temporary SSE 5xx or idle
|
|
76
|
+
* watchdog expiry raises {@link BoardFeedRetryableError}; it never mutates the
|
|
77
|
+
* mode to polling. A caller retries the same instance and therefore the same
|
|
78
|
+
* declared transport. Polling exists only when `board.sse` was absent from the
|
|
79
|
+
* declaration in the first place.
|
|
80
|
+
*/
|
|
81
|
+
export declare class BoardFeedClient {
|
|
82
|
+
#private;
|
|
83
|
+
readonly mode: BoardFeedMode;
|
|
84
|
+
constructor(options: BoardFeedClientOptions);
|
|
85
|
+
readOnce(afterSeq: number, signal?: AbortSignal): Promise<BoardFeedRead>;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ActivityTail } from '@byok-sdk/core';
|
|
2
|
+
import type { AgentEventOrUnknown } from '@byok-sdk/protocol';
|
|
3
|
+
import type { TenantBoundActivity } from './tenant-stores';
|
|
4
|
+
export declare const DEFAULT_BOARD_CHANNEL_MAX_BYTES = 128;
|
|
5
|
+
export declare const DEFAULT_BOARD_TITLE_MAX_BYTES = 512;
|
|
6
|
+
export declare const DEFAULT_ACTIVITY_MAX_EVENTS = 50;
|
|
7
|
+
export declare const DEFAULT_ACTIVITY_MAX_BYTES: number;
|
|
8
|
+
export declare const DEFAULT_ACTIVITY_CAPACITY = 50;
|
|
9
|
+
export declare const DEFAULT_ACTIVITY_TTL_MS: number;
|
|
10
|
+
export declare const DEFAULT_PRESENCE_TTL_MS = 90000;
|
|
11
|
+
export declare const DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS = 5000;
|
|
12
|
+
export declare const DEFAULT_PRESENCE_DETAIL_MAX_BYTES = 512;
|
|
13
|
+
export interface ActivityBounds {
|
|
14
|
+
readonly maxEvents: number;
|
|
15
|
+
readonly maxBytes: number;
|
|
16
|
+
readonly capacity: number;
|
|
17
|
+
readonly ttlMs: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const DEFAULT_ACTIVITY_BOUNDS: ActivityBounds;
|
|
20
|
+
export declare function assertBoardLabels(channel: string, title: string, limits: {
|
|
21
|
+
readonly channelMaxBytes: number;
|
|
22
|
+
readonly titleMaxBytes: number;
|
|
23
|
+
}): void;
|
|
24
|
+
export declare function activityDetails(events: readonly AgentEventOrUnknown[], dropped: number, bounds: ActivityBounds): readonly string[];
|
|
25
|
+
export declare function appendActivityEvents(activity: TenantBoundActivity, input: {
|
|
26
|
+
readonly taskId: string;
|
|
27
|
+
readonly events: readonly AgentEventOrUnknown[];
|
|
28
|
+
readonly dropped: number;
|
|
29
|
+
}, bounds: ActivityBounds): Promise<ActivityTail>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The crypto seam.
|
|
3
|
+
*
|
|
4
|
+
* `@byok-sdk/server` reaches straight for `node:crypto`. Cloud cannot: a hosted
|
|
5
|
+
* composition has to load on Workers and Deno too, and `src/__tests__/
|
|
6
|
+
* constraints.test.ts` asserts this package never imports a `node:` module.
|
|
7
|
+
* So every primitive the device surface needs — random identifiers, Ed25519
|
|
8
|
+
* verification of a challenge signature, HMAC for presigned blob URLs,
|
|
9
|
+
* SHA-256 for content addressing — arrives through this port.
|
|
10
|
+
*
|
|
11
|
+
* `web-crypto.ts` in this directory is the reference implementation, built on
|
|
12
|
+
* the WebCrypto API that Node >=20, Workers, and Deno all expose as
|
|
13
|
+
* `globalThis.crypto`. A composition backed by a KMS or an HSM supplies its
|
|
14
|
+
* own instead.
|
|
15
|
+
*/
|
|
16
|
+
export interface CloudCrypto {
|
|
17
|
+
/** A fresh UUID v4 — envelope ids, device ids, blob ids, task ids. */
|
|
18
|
+
randomUuid(): string;
|
|
19
|
+
/** `byteLength` random bytes, base64url-encoded — nonces and opaque tokens. */
|
|
20
|
+
randomToken(byteLength: number): string;
|
|
21
|
+
/** A short, human-typeable pairing code (uppercase, unambiguous alphabet). */
|
|
22
|
+
randomPairingCode(length: number): string;
|
|
23
|
+
/**
|
|
24
|
+
* Verify `signature` (base64url) over `message` against
|
|
25
|
+
* `publicKeyBase64Url` — a raw 32-byte Ed25519 public key in the same
|
|
26
|
+
* base64url encoding a JWK's `x` field uses, which is what a device
|
|
27
|
+
* registers at pairing time. String input is encoded as UTF-8; byte input is
|
|
28
|
+
* used verbatim so core's frozen device-proof signing bytes are not decoded
|
|
29
|
+
* and re-encoded by the adapter.
|
|
30
|
+
*
|
|
31
|
+
* Never throws: a malformed key or signature is a failed verification, not
|
|
32
|
+
* an exception a route has to translate.
|
|
33
|
+
*/
|
|
34
|
+
verifyEd25519(publicKeyBase64Url: string, message: string | Uint8Array, signature: string): Promise<boolean>;
|
|
35
|
+
/** HMAC-SHA-256 over `message`, base64url-encoded. Used for presigned blob URLs. */
|
|
36
|
+
hmacSha256(secret: Uint8Array, message: string): Promise<string>;
|
|
37
|
+
/** SHA-256 of `data`, in core's canonical `sha256:<64 lowercase hex>` form. */
|
|
38
|
+
sha256(data: Uint8Array): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Length-independent equality for two base64url strings. Signature
|
|
41
|
+
* comparison must not leak a prefix through timing.
|
|
42
|
+
*/
|
|
43
|
+
timingSafeEqual(left: string, right: string): boolean;
|
|
44
|
+
}
|