@byok-sdk/core 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 +11 -0
- package/dist/attestation.d.ts +153 -0
- package/dist/blob.d.ts +116 -0
- package/dist/board.d.ts +120 -0
- package/dist/capabilities.d.ts +38 -0
- package/dist/errors.d.ts +87 -0
- package/dist/in-memory/blob.d.ts +24 -0
- package/dist/in-memory/board.d.ts +26 -0
- package/dist/in-memory/clock.d.ts +12 -0
- package/dist/in-memory/index.d.ts +23 -0
- package/dist/in-memory/mailbox.d.ts +12 -0
- package/dist/in-memory/presence.d.ts +16 -0
- package/dist/in-memory/quota.d.ts +18 -0
- package/dist/in-memory/truth.d.ts +11 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +1498 -0
- package/dist/index.js.map +1 -0
- package/dist/mailbox.d.ts +115 -0
- package/dist/ports-contract.d.ts +26 -0
- package/dist/presence.d.ts +95 -0
- package/dist/principals.d.ts +54 -0
- package/dist/quota.d.ts +189 -0
- package/dist/stores.d.ts +54 -0
- package/dist/tenant.d.ts +48 -0
- package/dist/time.d.ts +24 -0
- package/dist/truth.d.ts +109 -0
- package/package.json +49 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence and activity hints (§12.3).
|
|
3
|
+
*
|
|
4
|
+
* Both are lossy, TTL-bounded, unsigned, and never authoritative. Expiry means
|
|
5
|
+
* *absence*, not a stale value: a hint past its TTL is invisible to readers, so
|
|
6
|
+
* nothing downstream can mistake an old level for a live one.
|
|
7
|
+
*
|
|
8
|
+
* These hints must never be used to derive coordination state, execution state,
|
|
9
|
+
* authorization, billing, or recovery — which is why this module shares no
|
|
10
|
+
* vocabulary with `board.ts` and no vocabulary with the frozen wire states. The
|
|
11
|
+
* constraint test enforces that separation by scanning this file for those
|
|
12
|
+
* names. A device that looks busy is not evidence that any particular work item
|
|
13
|
+
* moved anywhere.
|
|
14
|
+
*
|
|
15
|
+
* The activity tail is bounded by count and carries an explicit `dropped`
|
|
16
|
+
* counter: lossiness is written into the data instead of being hidden behind a
|
|
17
|
+
* stream that pretends to be complete.
|
|
18
|
+
*/
|
|
19
|
+
import type { TenantId } from './tenant';
|
|
20
|
+
/** The five presence levels. */
|
|
21
|
+
export declare const PRESENCE_LEVELS: readonly ['online', 'thinking', 'working', 'error', 'offline'];
|
|
22
|
+
export type PresenceLevel = (typeof PRESENCE_LEVELS)[number];
|
|
23
|
+
/** A device-level hint. `expiresAt` is authoritative: past it, the hint does not exist. */
|
|
24
|
+
export interface PresenceHint {
|
|
25
|
+
readonly tenantId: TenantId;
|
|
26
|
+
readonly deviceId: string;
|
|
27
|
+
readonly level: PresenceLevel;
|
|
28
|
+
/** Free-form host label, bounded by the composition. Never parsed by core. */
|
|
29
|
+
readonly detail?: string;
|
|
30
|
+
readonly observedAt: string;
|
|
31
|
+
readonly expiresAt: string;
|
|
32
|
+
}
|
|
33
|
+
export interface PresenceHintInput {
|
|
34
|
+
readonly deviceId: string;
|
|
35
|
+
readonly level: PresenceLevel;
|
|
36
|
+
readonly detail?: string;
|
|
37
|
+
/** Hint lifetime. §12.7.5 suggests 60-120s for presence. */
|
|
38
|
+
readonly ttlMs: number;
|
|
39
|
+
/** Minimum time between accepted publications for this device. `0` explicitly disables throttling. */
|
|
40
|
+
readonly minimumIntervalMs: number;
|
|
41
|
+
}
|
|
42
|
+
/** One entry of a task's lossy tail. */
|
|
43
|
+
export interface ActivityEntry {
|
|
44
|
+
readonly at: string;
|
|
45
|
+
readonly detail: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A task's bounded tail.
|
|
49
|
+
*
|
|
50
|
+
* `dropped` counts entries evicted by `capacity`, so a reader can tell the
|
|
51
|
+
* difference between "nothing happened" and "we lost the middle".
|
|
52
|
+
*/
|
|
53
|
+
export interface ActivityTail {
|
|
54
|
+
readonly tenantId: TenantId;
|
|
55
|
+
readonly taskId: string;
|
|
56
|
+
readonly entries: readonly ActivityEntry[];
|
|
57
|
+
readonly dropped: number;
|
|
58
|
+
readonly capacity: number;
|
|
59
|
+
readonly expiresAt: string;
|
|
60
|
+
}
|
|
61
|
+
export interface ActivityAppendInput {
|
|
62
|
+
readonly taskId: string;
|
|
63
|
+
/** One ProgressBatcher-shaped batch. Empty batches are rejected. */
|
|
64
|
+
readonly details: readonly string[];
|
|
65
|
+
/** Events the producer dropped before this batch reached the store. */
|
|
66
|
+
readonly dropped: number;
|
|
67
|
+
/** Tail lifetime. §12.7.5 suggests 5-15 minutes for activity. */
|
|
68
|
+
readonly ttlMs: number;
|
|
69
|
+
/** Maximum retained entries. Must be a positive integer. */
|
|
70
|
+
readonly capacity?: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Presence port. Tenant-first, async.
|
|
74
|
+
*
|
|
75
|
+
* Reads filter expired hints out rather than returning them with a flag: an
|
|
76
|
+
* expired hint is indistinguishable from one that was never written.
|
|
77
|
+
*/
|
|
78
|
+
export interface PresenceStore {
|
|
79
|
+
publish(tenant: TenantId, input: PresenceHintInput): Promise<PresenceHint>;
|
|
80
|
+
read(tenant: TenantId, deviceId: string): Promise<PresenceHint | undefined>;
|
|
81
|
+
list(tenant: TenantId): Promise<readonly PresenceHint[]>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Activity port. Tenant-first, async.
|
|
85
|
+
*
|
|
86
|
+
* Raises: `activity_capacity_invalid` (non-positive or non-integer capacity —
|
|
87
|
+
* an unbounded tail is exactly what this contract exists to prevent),
|
|
88
|
+
* `activity_batch_invalid` (empty batch or invalid producer drop count).
|
|
89
|
+
*/
|
|
90
|
+
export interface ActivityStore {
|
|
91
|
+
append(tenant: TenantId, input: ActivityAppendInput): Promise<ActivityTail>;
|
|
92
|
+
read(tenant: TenantId, taskId: string): Promise<ActivityTail | undefined>;
|
|
93
|
+
}
|
|
94
|
+
/** Default tail capacity when an append does not specify one. */
|
|
95
|
+
export declare const DEFAULT_ACTIVITY_CAPACITY = 50;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated principals — layer 2 of the isolation model (§12.6.2).
|
|
3
|
+
*
|
|
4
|
+
* A handler never receives a raw tenant string; it receives a principal that
|
|
5
|
+
* already carries a minted {@link TenantId}. The two principal shapes are
|
|
6
|
+
* deliberately not one type with an optional `deviceId`: a control-plane caller
|
|
7
|
+
* that can write entitlements and a device that can write truth records have
|
|
8
|
+
* different authority, and a discriminated union makes a handler state which
|
|
9
|
+
* one it accepts.
|
|
10
|
+
*
|
|
11
|
+
* `keyId`/`keyEpoch` are **not** here. They are device-proof semantics
|
|
12
|
+
* (`plans/sprints/…sprint.md` §S6.2): the signing key's identity and rotation
|
|
13
|
+
* generation, resolved by looking up the device row during proof verification.
|
|
14
|
+
* Putting them on the principal would create permanently-empty fields on every
|
|
15
|
+
* principal minted by a non-proof path.
|
|
16
|
+
*/
|
|
17
|
+
import type { TenantId } from './tenant';
|
|
18
|
+
/** Principal kinds. A composition may not invent a third without a contract change. */
|
|
19
|
+
export declare const PRINCIPAL_KINDS: readonly ['device', 'control-plane'];
|
|
20
|
+
export type PrincipalKind = (typeof PRINCIPAL_KINDS)[number];
|
|
21
|
+
/**
|
|
22
|
+
* A paired device acting inside one tenant/product.
|
|
23
|
+
*
|
|
24
|
+
* Built only from a device row loaded with the tenant as part of the lookup key
|
|
25
|
+
* (§12.6.2 layer 5) — never from claims a device asserted about itself.
|
|
26
|
+
*/
|
|
27
|
+
export interface DevicePrincipal {
|
|
28
|
+
readonly kind: 'device';
|
|
29
|
+
readonly tenantId: TenantId;
|
|
30
|
+
readonly productId: string;
|
|
31
|
+
readonly deviceId: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The host's control plane acting on a tenant: entitlement writes, retention
|
|
35
|
+
* policy, administrative reads. Carries the operator identity for audit, which
|
|
36
|
+
* is opaque to the SDK.
|
|
37
|
+
*/
|
|
38
|
+
export interface ControlPlanePrincipal {
|
|
39
|
+
readonly kind: 'control-plane';
|
|
40
|
+
readonly tenantId: TenantId;
|
|
41
|
+
readonly operatorId: string;
|
|
42
|
+
}
|
|
43
|
+
/** Anything that can address a tenant-scoped store. */
|
|
44
|
+
export type Principal = DevicePrincipal | ControlPlanePrincipal;
|
|
45
|
+
export declare function isDevicePrincipal(principal: Principal): principal is DevicePrincipal;
|
|
46
|
+
export declare function isControlPlanePrincipal(principal: Principal): principal is ControlPlanePrincipal;
|
|
47
|
+
/**
|
|
48
|
+
* The tenant every store call must be scoped to.
|
|
49
|
+
*
|
|
50
|
+
* Exists so a handler cannot accidentally read `principal.tenantId` off one
|
|
51
|
+
* principal and pass a different tenant to a store: the facade that binds
|
|
52
|
+
* stores to a tenant takes this, not a loose string.
|
|
53
|
+
*/
|
|
54
|
+
export declare function principalTenant(principal: Principal): TenantId;
|
package/dist/quota.d.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant storage entitlement, usage, reservation and retention (§12.7.6-12.7.7).
|
|
3
|
+
*
|
|
4
|
+
* The SDK does not know what a plan is. It never sees `free`, `pro`, a price, a
|
|
5
|
+
* currency, or a purchase flow — those belong to the host SaaS. What crosses
|
|
6
|
+
* this boundary is a *numeric, versioned* entitlement the host issues, and the
|
|
7
|
+
* constraint test asserts this file contains no plan-name or price vocabulary.
|
|
8
|
+
* The moment the SDK hardcodes a tier, every host is stuck with the SDK's
|
|
9
|
+
* commercial model.
|
|
10
|
+
*
|
|
11
|
+
* Byte counts are `bigint`. Serialization is a composition concern: JSON has no
|
|
12
|
+
* bigint, so a cloud handler renders them as decimal strings on the wire. Using
|
|
13
|
+
* `number` here would put a silent 2^53 ceiling into a storage quota contract.
|
|
14
|
+
*
|
|
15
|
+
* Reservation exists because Postgres and R2 have no shared transaction
|
|
16
|
+
* (§12.7.7). Every durable write reserves first, uploads second, finalizes
|
|
17
|
+
* third; the invariant `committed + reserved + expected <= hardLimit` is
|
|
18
|
+
* checked under the reservation lock, which is what makes concurrent uploads
|
|
19
|
+
* unable to oversell the tenant.
|
|
20
|
+
*/
|
|
21
|
+
import type { ContentHash } from './blob';
|
|
22
|
+
import type { TenantId } from './tenant';
|
|
23
|
+
/**
|
|
24
|
+
* The host-issued numeric entitlement (§12.7.6, verbatim).
|
|
25
|
+
*
|
|
26
|
+
* `version` is monotonic and CAS-checked on write: a delayed control-plane
|
|
27
|
+
* update must not resurrect an older plan over a newer one.
|
|
28
|
+
*/
|
|
29
|
+
export interface TenantStorageEntitlement {
|
|
30
|
+
tenantId: TenantId;
|
|
31
|
+
version: bigint;
|
|
32
|
+
hardLimitBytes: bigint;
|
|
33
|
+
maxObjectBytes: bigint;
|
|
34
|
+
maxInlineBytes: bigint;
|
|
35
|
+
mailboxLimitBytes: bigint;
|
|
36
|
+
retentionPolicyId: string;
|
|
37
|
+
/** Canonical ISO-8601 UTC instant — see {@link TenantStorageEntitlementInput}. */
|
|
38
|
+
downgradeGraceUntil?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Measured tenant usage (§12.7.6, verbatim).
|
|
42
|
+
*
|
|
43
|
+
* Carries no `tenantId` because it is always read through a tenant-scoped port
|
|
44
|
+
* call — the tenant is the query, not a field of the answer.
|
|
45
|
+
*/
|
|
46
|
+
export interface TenantStorageUsage {
|
|
47
|
+
committedObjectBytes: bigint;
|
|
48
|
+
committedInlineBytes: bigint;
|
|
49
|
+
reservedBytes: bigint;
|
|
50
|
+
mailboxBytes: bigint;
|
|
51
|
+
objectCount: bigint;
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
}
|
|
54
|
+
/** Entitlement write payload. `tenantId` comes from the port's first parameter. */
|
|
55
|
+
export interface TenantStorageEntitlementInput {
|
|
56
|
+
readonly version: bigint;
|
|
57
|
+
readonly hardLimitBytes: bigint;
|
|
58
|
+
readonly maxObjectBytes: bigint;
|
|
59
|
+
readonly maxInlineBytes: bigint;
|
|
60
|
+
readonly mailboxLimitBytes: bigint;
|
|
61
|
+
readonly retentionPolicyId: string;
|
|
62
|
+
/**
|
|
63
|
+
* Deadline after which an over-limit tenant is suspended rather than blocked.
|
|
64
|
+
*
|
|
65
|
+
* Must be a **canonical ISO-8601 UTC instant** (`YYYY-MM-DDTHH:mm:ss.sssZ`);
|
|
66
|
+
* anything else is rejected with `timestamp_not_canonical`. The in-memory
|
|
67
|
+
* composition compares this deadline as a string and a SQL composition
|
|
68
|
+
* compares it as a `timestamptz`, and those two agree only on the canonical
|
|
69
|
+
* form — see `time.ts`.
|
|
70
|
+
*/
|
|
71
|
+
readonly downgradeGraceUntil?: string;
|
|
72
|
+
}
|
|
73
|
+
/** Durable write classes a reservation can cover. */
|
|
74
|
+
export declare const STORAGE_WRITE_KINDS: readonly ['object', 'inline'];
|
|
75
|
+
export type StorageWriteKind = (typeof STORAGE_WRITE_KINDS)[number];
|
|
76
|
+
export declare const STORAGE_RESERVATION_STATES: readonly ['reserved', 'committed', 'aborted', 'expired'];
|
|
77
|
+
export type StorageReservationState = (typeof STORAGE_RESERVATION_STATES)[number];
|
|
78
|
+
export interface StorageReservation {
|
|
79
|
+
readonly tenantId: TenantId;
|
|
80
|
+
readonly reservationId: string;
|
|
81
|
+
readonly state: StorageReservationState;
|
|
82
|
+
readonly kind: StorageWriteKind;
|
|
83
|
+
readonly expectedBytes: bigint;
|
|
84
|
+
readonly contentHash: ContentHash;
|
|
85
|
+
readonly contentType: string;
|
|
86
|
+
readonly createdAt: string;
|
|
87
|
+
readonly expiresAt: string;
|
|
88
|
+
readonly settledAt?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface StorageReservationInput {
|
|
91
|
+
readonly reservationId: string;
|
|
92
|
+
readonly kind: StorageWriteKind;
|
|
93
|
+
readonly expectedBytes: bigint;
|
|
94
|
+
readonly contentHash: ContentHash;
|
|
95
|
+
readonly contentType: string;
|
|
96
|
+
readonly ttlMs: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Finalize payload. Size/type are what the composition observed on the object
|
|
100
|
+
* store, not what the client promised — disagreement is
|
|
101
|
+
* `storage_integrity_mismatch`.
|
|
102
|
+
*
|
|
103
|
+
* Hash identity stays on the reservation as the authenticated daemon's
|
|
104
|
+
* declaration (ADR-024). Object-store HEAD does not independently observe a
|
|
105
|
+
* SHA-256 digest, so accepting one here would turn a copied declaration into a
|
|
106
|
+
* false verification claim.
|
|
107
|
+
*/
|
|
108
|
+
export interface StorageFinalizeInput {
|
|
109
|
+
readonly reservationId: string;
|
|
110
|
+
readonly observedByteSize: bigint;
|
|
111
|
+
readonly observedContentType: string;
|
|
112
|
+
}
|
|
113
|
+
export interface StorageFinalizeResult {
|
|
114
|
+
readonly reservation: StorageReservation;
|
|
115
|
+
readonly usage: TenantStorageUsage;
|
|
116
|
+
/**
|
|
117
|
+
* True when this tenant already had the same content hash committed. The
|
|
118
|
+
* reserved bytes are released and nothing is added: same tenant, same hash,
|
|
119
|
+
* counted once (§12.7.6). Cross-tenant sharing never happens — usage is
|
|
120
|
+
* per-tenant even when the bytes are identical.
|
|
121
|
+
*/
|
|
122
|
+
readonly deduplicated: boolean;
|
|
123
|
+
}
|
|
124
|
+
/** Effective write posture derived from entitlement + usage + clock (§12.7.8). */
|
|
125
|
+
export declare const STORAGE_WRITE_POSTURES: readonly ['normal', 'warning', 'blocked', 'suspended'];
|
|
126
|
+
export type StorageWritePosture = (typeof STORAGE_WRITE_POSTURES)[number];
|
|
127
|
+
export interface StorageStatus {
|
|
128
|
+
readonly entitlement: TenantStorageEntitlement;
|
|
129
|
+
readonly usage: TenantStorageUsage;
|
|
130
|
+
readonly posture: StorageWritePosture;
|
|
131
|
+
/** `committed + reserved` against `hardLimitBytes`, for host UI. */
|
|
132
|
+
readonly availableBytes: bigint;
|
|
133
|
+
readonly graceActive: boolean;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The five wire-stable storage error codes (§12.7.7). Renaming one is a
|
|
137
|
+
* breaking change for every host that branches on them.
|
|
138
|
+
*/
|
|
139
|
+
export declare const STORAGE_ERROR_CODES: readonly ["storage_object_too_large", "storage_quota_exceeded", "storage_reservation_expired", "storage_integrity_mismatch", "storage_write_suspended"];
|
|
140
|
+
export type StorageErrorCode = (typeof STORAGE_ERROR_CODES)[number];
|
|
141
|
+
/** HTTP status mapping from §12.7.7. Compositions render these verbatim. */
|
|
142
|
+
export declare const STORAGE_ERROR_HTTP_STATUS: {
|
|
143
|
+
readonly storage_object_too_large: 413;
|
|
144
|
+
readonly storage_quota_exceeded: 507;
|
|
145
|
+
readonly storage_reservation_expired: 409;
|
|
146
|
+
readonly storage_integrity_mismatch: 422;
|
|
147
|
+
readonly storage_write_suspended: 423;
|
|
148
|
+
};
|
|
149
|
+
export interface MailboxUsageDeltaInput {
|
|
150
|
+
/** Signed. Negative deltas release bytes after retention deletes rows. */
|
|
151
|
+
readonly deltaBytes: bigint;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Quota port. Tenant-first, async.
|
|
155
|
+
*
|
|
156
|
+
* Raises: `storage_entitlement_missing`,
|
|
157
|
+
* `storage_entitlement_version_conflict` (carries the current entitlement),
|
|
158
|
+
* `storage_reservation_not_found`, and the five wire-stable codes above.
|
|
159
|
+
*/
|
|
160
|
+
export interface QuotaStore {
|
|
161
|
+
readEntitlement(tenant: TenantId): Promise<TenantStorageEntitlement | undefined>;
|
|
162
|
+
/**
|
|
163
|
+
* Version CAS: a write at or below the stored version is rejected with the
|
|
164
|
+
* current row. Raises `timestamp_not_canonical` when `downgradeGraceUntil` is
|
|
165
|
+
* not a canonical ISO-8601 UTC instant.
|
|
166
|
+
*/
|
|
167
|
+
writeEntitlement(tenant: TenantId, input: TenantStorageEntitlementInput): Promise<TenantStorageEntitlement>;
|
|
168
|
+
readUsage(tenant: TenantId): Promise<TenantStorageUsage>;
|
|
169
|
+
readStatus(tenant: TenantId): Promise<StorageStatus>;
|
|
170
|
+
/** Tenant-scoped lookup used to bind a finalize request to its reservation. */
|
|
171
|
+
readReservation(tenant: TenantId, reservationId: string): Promise<StorageReservation | undefined>;
|
|
172
|
+
/**
|
|
173
|
+
* Atomically checks `committed + reserved + expected <= hardLimitBytes` and,
|
|
174
|
+
* on success, adds `expectedBytes` to `reservedBytes`.
|
|
175
|
+
*/
|
|
176
|
+
reserve(tenant: TenantId, input: StorageReservationInput): Promise<StorageReservation>;
|
|
177
|
+
/**
|
|
178
|
+
* Moves reserved bytes to committed after verifying the observed object.
|
|
179
|
+
* For object reservations, the composition commits the matching manifest in
|
|
180
|
+
* the same authority step as reservation/accounting settlement.
|
|
181
|
+
*/
|
|
182
|
+
finalizeReservation(tenant: TenantId, input: StorageFinalizeInput): Promise<StorageFinalizeResult>;
|
|
183
|
+
/** Idempotent release. Already-settled reservations return their settled row. */
|
|
184
|
+
abortReservation(tenant: TenantId, reservationId: string): Promise<StorageReservation>;
|
|
185
|
+
/** Cleanup hook: expires reservations past their TTL and releases their bytes. */
|
|
186
|
+
expireReservations(tenant: TenantId): Promise<readonly StorageReservation[]>;
|
|
187
|
+
/** Mailbox bytes are platform-protection accounting, bounded by `mailboxLimitBytes`. */
|
|
188
|
+
applyMailboxDelta(tenant: TenantId, input: MailboxUsageDeltaInput): Promise<TenantStorageUsage>;
|
|
189
|
+
}
|
package/dist/stores.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composition contract: the full set of store ports, plus the clock seam.
|
|
3
|
+
*
|
|
4
|
+
* Every method on every port in this package obeys two rules, and
|
|
5
|
+
* `src/__tests__/constraints.test.ts` enumerates them method by method to prove
|
|
6
|
+
* it (sprint I7):
|
|
7
|
+
*
|
|
8
|
+
* 1. **Async.** Every method returns a `Promise`. A synchronous port would
|
|
9
|
+
* silently exclude SQL and object-store compositions from the contract.
|
|
10
|
+
* 2. **Tenant-first.** Every method's first parameter is a required
|
|
11
|
+
* {@link TenantId}. There is no bare `deviceId`/`taskId` lookup anywhere —
|
|
12
|
+
* §12.6.2 layer 3 forbids the index such a lookup would need, and layer 5
|
|
13
|
+
* requires the tenant to be part of the lookup key rather than a second
|
|
14
|
+
* comparison step that can be forgotten.
|
|
15
|
+
*
|
|
16
|
+
* No port method can change the tenant of an existing row. That is not an
|
|
17
|
+
* omission: "move this to another tenant" is the one operation that would make
|
|
18
|
+
* every cross-tenant assertion in the conformance suite unfalsifiable.
|
|
19
|
+
*/
|
|
20
|
+
import type { ActivityStore, PresenceStore } from './presence';
|
|
21
|
+
import type { BoardStore } from './board';
|
|
22
|
+
import type { MailboxStore } from './mailbox';
|
|
23
|
+
import type { ObjectStore } from './blob';
|
|
24
|
+
import type { QuotaStore } from './quota';
|
|
25
|
+
import type { TruthStore } from './truth';
|
|
26
|
+
/**
|
|
27
|
+
* Injected time.
|
|
28
|
+
*
|
|
29
|
+
* TTL semantics (presence expiry, activity expiry, reservation expiry) are
|
|
30
|
+
* behavior the conformance suite has to assert deterministically, which is
|
|
31
|
+
* impossible against a wall clock. Compositions inject one; nothing in core
|
|
32
|
+
* calls `Date.now()` directly.
|
|
33
|
+
*/
|
|
34
|
+
export interface Clock {
|
|
35
|
+
now(): Date;
|
|
36
|
+
}
|
|
37
|
+
/** A clock pinned to a fixed instant, advanced explicitly. Used by tests and the in-memory reference. */
|
|
38
|
+
export interface MutableClock extends Clock {
|
|
39
|
+
advance(ms: number): void;
|
|
40
|
+
set(instant: Date): void;
|
|
41
|
+
}
|
|
42
|
+
/** Every port a composition must supply. */
|
|
43
|
+
export interface CoreStores {
|
|
44
|
+
readonly mailbox: MailboxStore;
|
|
45
|
+
readonly board: BoardStore;
|
|
46
|
+
readonly truth: TruthStore;
|
|
47
|
+
readonly presence: PresenceStore;
|
|
48
|
+
readonly activity: ActivityStore;
|
|
49
|
+
readonly objects: ObjectStore;
|
|
50
|
+
readonly quota: QuotaStore;
|
|
51
|
+
}
|
|
52
|
+
/** Names of the ports in {@link CoreStores}, in contract order. */
|
|
53
|
+
export declare const CORE_STORE_NAMES: readonly ['mailbox', 'board', 'truth', 'presence', 'activity', 'objects', 'quota'];
|
|
54
|
+
export type CoreStoreName = (typeof CORE_STORE_NAMES)[number];
|
package/dist/tenant.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A validated tenant identifier.
|
|
3
|
+
*
|
|
4
|
+
* Nominal by construction: structurally a `string`, but the phantom brand makes
|
|
5
|
+
* it unassignable from an arbitrary string. Assignment the other way
|
|
6
|
+
* (`TenantId` → `string`) stays legal on purpose, so composition code can pass
|
|
7
|
+
* it as a SQL parameter or key prefix without a cast.
|
|
8
|
+
*/
|
|
9
|
+
export type TenantId = string & {
|
|
10
|
+
readonly __byokTenantId: unique symbol;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Upper bound on tenant id length. Not a security boundary — a bound so a
|
|
14
|
+
* pathological id cannot become an unbounded key prefix inside a store.
|
|
15
|
+
*/
|
|
16
|
+
export declare const TENANT_ID_MAX_LENGTH = 200;
|
|
17
|
+
/**
|
|
18
|
+
* Separator for flat composite keys. `NUL` cannot appear in a tenant id that a
|
|
19
|
+
* control plane can express in a URL, a header, or a SQL identifier, and
|
|
20
|
+
* {@link tenantId} rejects it outright — so a composite key is never ambiguous
|
|
21
|
+
* between `(a, bc)` and `(ab, c)`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TENANT_KEY_SEPARATOR = "\0";
|
|
24
|
+
/**
|
|
25
|
+
* The single mint point for {@link TenantId}.
|
|
26
|
+
*
|
|
27
|
+
* Fail-closed: empty, whitespace-padded, over-long, non-string, or
|
|
28
|
+
* `NUL`-bearing values are rejected rather than normalized. Normalizing here
|
|
29
|
+
* would create a second source of truth for "which tenant is this", because the
|
|
30
|
+
* control plane that issued the id would then disagree with the SDK about its
|
|
31
|
+
* canonical form.
|
|
32
|
+
*
|
|
33
|
+
* @throws {ByokCoreError} code `tenant_id_invalid`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function tenantId(value: string): TenantId;
|
|
36
|
+
/**
|
|
37
|
+
* Non-throwing form of {@link tenantId}, for surfaces that must answer "is this
|
|
38
|
+
* a tenant id?" without building an error (route matching, log redaction).
|
|
39
|
+
* Accepts exactly what {@link tenantId} accepts, never more.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isTenantId(value: unknown): value is TenantId;
|
|
42
|
+
/**
|
|
43
|
+
* Composite key helper for compositions that need a flat key space (in-memory
|
|
44
|
+
* maps, KV namespaces). SQL compositions use a tenant-prefixed composite
|
|
45
|
+
* primary key instead — §12.6.2 layer 3 forbids a bare device/task index
|
|
46
|
+
* regardless of which representation a composition picks.
|
|
47
|
+
*/
|
|
48
|
+
export declare function tenantKey(tenant: TenantId, ...parts: readonly string[]): string;
|
package/dist/time.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exactly what `toISOString()` produces: four-digit year, `T` separator,
|
|
3
|
+
* millisecond precision, literal `Z`. No offsets, no omitted milliseconds, no
|
|
4
|
+
* expanded-year form — each of those breaks the lexicographic-order property.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CANONICAL_TIMESTAMP_PATTERN: RegExp;
|
|
7
|
+
/**
|
|
8
|
+
* True when `value` is a canonical ISO-8601 UTC instant.
|
|
9
|
+
*
|
|
10
|
+
* Two checks, in order: the pattern pins the *shape*, then a round trip
|
|
11
|
+
* through `Date` pins *calendar validity* — `2026-02-30T00:00:00.000Z` matches
|
|
12
|
+
* the pattern and is not an instant.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isCanonicalTimestamp(value: unknown): value is string;
|
|
15
|
+
/**
|
|
16
|
+
* Fail-closed gate for a caller-supplied instant.
|
|
17
|
+
*
|
|
18
|
+
* @param value The timestamp as received from the caller.
|
|
19
|
+
* @param field Field name, so the failure names the contract that was missed
|
|
20
|
+
* rather than just the bad string.
|
|
21
|
+
* @returns The same string, so call sites can validate and assign in one step.
|
|
22
|
+
* @throws {ByokCoreError} code `timestamp_not_canonical`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function assertCanonicalTimestamp(value: string, field: string): string;
|
package/dist/truth.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truth records: attested metadata with two different write models (§12.3, §12.6.4).
|
|
3
|
+
*
|
|
4
|
+
* | kind | write model | conflict model |
|
|
5
|
+
* | --------------- | ------------------------------ | --------------------------------------- |
|
|
6
|
+
* | `task.terminal` | first write per task, immutable | different hash → `terminal_conflict` |
|
|
7
|
+
* | `profile` | per-key snapshot | `expectedRev` CAS |
|
|
8
|
+
* | `memory` | per-key snapshot | `expectedRev` CAS |
|
|
9
|
+
*
|
|
10
|
+
* The store is deliberately dumb about content. It can match, sort, and return
|
|
11
|
+
* by tenant/kind/key/rev/hash, and that is the whole of its authority: no
|
|
12
|
+
* summarizing, no merging, no relevance ranking (§12.3). Merge decisions belong
|
|
13
|
+
* to the device that holds the context, which is why a conflict hands back the
|
|
14
|
+
* current snapshot and stops.
|
|
15
|
+
*
|
|
16
|
+
* The manifest listing returns metadata only — never bodies. Selecting which
|
|
17
|
+
* bodies to fetch is a local decision (§S6.4), so shipping bodies in the list
|
|
18
|
+
* response would both defeat that and make the response unbounded.
|
|
19
|
+
*/
|
|
20
|
+
import type { ContentHash } from './blob';
|
|
21
|
+
import type { TenantId } from './tenant';
|
|
22
|
+
export declare const TRUTH_RECORD_KINDS: readonly ['task.terminal', 'profile', 'memory'];
|
|
23
|
+
export type TruthRecordKind = (typeof TRUTH_RECORD_KINDS)[number];
|
|
24
|
+
/**
|
|
25
|
+
* Where the record body lives. Small payloads may be inline and still count
|
|
26
|
+
* against tenant usage (§12.7.6); larger ones are an object reference.
|
|
27
|
+
*/
|
|
28
|
+
export type TruthBodyRef = {
|
|
29
|
+
readonly kind: 'inline';
|
|
30
|
+
readonly body: string;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: 'object';
|
|
33
|
+
readonly hash: ContentHash;
|
|
34
|
+
};
|
|
35
|
+
export interface TruthRecord {
|
|
36
|
+
readonly tenantId: TenantId;
|
|
37
|
+
readonly kind: TruthRecordKind;
|
|
38
|
+
/** For `task.terminal` this is the task id; for snapshots, the host's key. */
|
|
39
|
+
readonly recordKey: string;
|
|
40
|
+
/** `1` for the first write. Terminal records never advance past `1`. */
|
|
41
|
+
readonly rev: number;
|
|
42
|
+
readonly contentHash: ContentHash;
|
|
43
|
+
readonly byteSize: bigint;
|
|
44
|
+
readonly body: TruthBodyRef;
|
|
45
|
+
readonly label?: string;
|
|
46
|
+
/** Idempotency key of the accepted write, for replay detection. */
|
|
47
|
+
readonly requestId?: string;
|
|
48
|
+
readonly writtenAt: string;
|
|
49
|
+
}
|
|
50
|
+
/** Manifest projection: everything needed to decide *whether* to fetch a body. */
|
|
51
|
+
export interface TruthManifestEntry {
|
|
52
|
+
readonly kind: TruthRecordKind;
|
|
53
|
+
readonly recordKey: string;
|
|
54
|
+
readonly rev: number;
|
|
55
|
+
readonly contentHash: ContentHash;
|
|
56
|
+
readonly byteSize: bigint;
|
|
57
|
+
readonly label?: string;
|
|
58
|
+
readonly updatedAt: string;
|
|
59
|
+
}
|
|
60
|
+
export interface TerminalWriteInput {
|
|
61
|
+
readonly taskId: string;
|
|
62
|
+
readonly contentHash: ContentHash;
|
|
63
|
+
readonly byteSize: bigint;
|
|
64
|
+
readonly body: TruthBodyRef;
|
|
65
|
+
readonly label?: string;
|
|
66
|
+
readonly requestId?: string;
|
|
67
|
+
}
|
|
68
|
+
export interface SnapshotWriteInput {
|
|
69
|
+
readonly kind: 'profile' | 'memory';
|
|
70
|
+
readonly recordKey: string;
|
|
71
|
+
/** `0` asserts "no record yet". Any other value must equal the stored `rev`. */
|
|
72
|
+
readonly expectedRev: number;
|
|
73
|
+
readonly contentHash: ContentHash;
|
|
74
|
+
readonly byteSize: bigint;
|
|
75
|
+
readonly body: TruthBodyRef;
|
|
76
|
+
readonly label?: string;
|
|
77
|
+
readonly requestId?: string;
|
|
78
|
+
}
|
|
79
|
+
export interface TruthRecordSelector {
|
|
80
|
+
readonly kind: TruthRecordKind;
|
|
81
|
+
readonly recordKey: string;
|
|
82
|
+
}
|
|
83
|
+
export interface TruthManifestQuery {
|
|
84
|
+
readonly kind?: TruthRecordKind;
|
|
85
|
+
readonly keyPrefix?: string;
|
|
86
|
+
readonly limit?: number;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Truth port. Tenant-first, async.
|
|
90
|
+
*
|
|
91
|
+
* Raises: `terminal_conflict` (same task, different hash — carries the record
|
|
92
|
+
* already committed), `truth_revision_conflict` (`expectedRev` missed — carries
|
|
93
|
+
* the current record), `truth_record_not_found`.
|
|
94
|
+
*/
|
|
95
|
+
export interface TruthStore {
|
|
96
|
+
/**
|
|
97
|
+
* Writes the first terminal record for a task.
|
|
98
|
+
*
|
|
99
|
+
* Replaying the identical hash returns the original record unchanged, so a
|
|
100
|
+
* retry after a lost response is safe. A different hash for the same task is
|
|
101
|
+
* a conflict: the first fact is never overwritten.
|
|
102
|
+
*/
|
|
103
|
+
writeTerminal(tenant: TenantId, input: TerminalWriteInput): Promise<TruthRecord>;
|
|
104
|
+
/** Per-key snapshot write under `expectedRev` CAS. The store never merges bodies. */
|
|
105
|
+
writeSnapshot(tenant: TenantId, input: SnapshotWriteInput): Promise<TruthRecord>;
|
|
106
|
+
getRecord(tenant: TenantId, selector: TruthRecordSelector): Promise<TruthRecord | undefined>;
|
|
107
|
+
/** Metadata only — key/rev/hash/size/label, no bodies. */
|
|
108
|
+
listManifest(tenant: TenantId, query: TruthManifestQuery): Promise<readonly TruthManifestEntry[]>;
|
|
109
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@byok-sdk/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "BYOK SDK platform contracts: branded tenant identity, tenant-first store ports, device proof canonicalization, and the composition conformance reference",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Ancienttwo/byok-sdk.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Ancienttwo/byok-sdk/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/Ancienttwo/byok-sdk#readme",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"zod": "^4.4.3"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup && tsc -p tsconfig.build.json",
|
|
43
|
+
"dev": "tsup --watch",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"clean": "rm -rf dist"
|
|
48
|
+
}
|
|
49
|
+
}
|