@abloatai/transaction 0.55.0 → 0.57.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/CONVENTIONS.md +34 -0
- package/dist/auth/hostedEndpoints.d.ts +21 -5
- package/dist/auth/hostedEndpoints.d.ts.map +1 -1
- package/dist/auth/hostedEndpoints.js +21 -5
- package/dist/auth/hostedEndpoints.js.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/schema.d.ts +0 -3
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +4 -5
- package/dist/coordination/schema.js.map +1 -1
- package/dist/errorCodes.d.ts +1 -0
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +1 -0
- package/dist/errorCodes.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -1
- package/dist/errors.js.map +1 -1
- package/dist/log/syncDeltaRow.d.ts +3 -3
- package/dist/readSetContext.d.ts.map +1 -1
- package/dist/readSetContext.js +8 -3
- package/dist/readSetContext.js.map +1 -1
- package/dist/resources/httpResources.d.ts +43 -22
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/resources/httpResources.js +81 -4
- package/dist/resources/httpResources.js.map +1 -1
- package/dist/resources/modelCreate.d.ts +21 -0
- package/dist/resources/modelCreate.d.ts.map +1 -0
- package/dist/resources/modelCreate.js +49 -0
- package/dist/resources/modelCreate.js.map +1 -0
- package/dist/resources/modelOperations.d.ts +24 -2
- package/dist/resources/modelOperations.d.ts.map +1 -1
- package/dist/resources/modelOperations.js.map +1 -1
- package/dist/resources/writeOptionsSchema.d.ts +14 -0
- package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
- package/dist/resources/writeOptionsSchema.js +23 -2
- package/dist/resources/writeOptionsSchema.js.map +1 -1
- package/dist/schema/audit.d.ts +15 -0
- package/dist/schema/audit.d.ts.map +1 -0
- package/dist/schema/audit.js +90 -0
- package/dist/schema/audit.js.map +1 -0
- package/dist/schema/ddl.d.ts.map +1 -1
- package/dist/schema/ddl.js +68 -1
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/index.d.ts +3 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +3 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/model.d.ts +14 -0
- package/dist/schema/model.d.ts.map +1 -1
- package/dist/schema/model.js +2 -0
- package/dist/schema/model.js.map +1 -1
- package/dist/schema/openapi.d.ts.map +1 -1
- package/dist/schema/openapi.js +104 -2
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/roles.d.ts +27 -0
- package/dist/schema/roles.d.ts.map +1 -1
- package/dist/schema/roles.js +40 -0
- package/dist/schema/roles.js.map +1 -1
- package/dist/schema/schema.d.ts +23 -4
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/schema.js +30 -1
- package/dist/schema/schema.js.map +1 -1
- package/dist/schema/serialize.d.ts +4 -1
- package/dist/schema/serialize.d.ts.map +1 -1
- package/dist/schema/serialize.js +5 -1
- package/dist/schema/serialize.js.map +1 -1
- package/dist/schema/subject.d.ts +18 -0
- package/dist/schema/subject.d.ts.map +1 -0
- package/dist/schema/subject.js +27 -0
- package/dist/schema/subject.js.map +1 -0
- package/dist/server/adapter.d.ts +2 -0
- package/dist/server/adapter.d.ts.map +1 -1
- package/dist/server/readConfig.d.ts +3 -0
- package/dist/server/readConfig.d.ts.map +1 -1
- package/dist/server/readConfig.js +0 -21
- package/dist/server/readConfig.js.map +1 -1
- package/dist/source/adapters/drizzle.d.ts.map +1 -1
- package/dist/source/adapters/drizzle.js +37 -6
- package/dist/source/adapters/drizzle.js.map +1 -1
- package/dist/source/adapters/kysely.d.ts +2 -0
- package/dist/source/adapters/kysely.d.ts.map +1 -1
- package/dist/source/adapters/kysely.js +33 -5
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.js +17 -8
- package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
- package/dist/source/adapters/memory.d.ts.map +1 -1
- package/dist/source/adapters/memory.js +1 -0
- package/dist/source/adapters/memory.js.map +1 -1
- package/dist/source/adapters/prisma.d.ts.map +1 -1
- package/dist/source/adapters/prisma.js +59 -7
- package/dist/source/adapters/prisma.js.map +1 -1
- package/dist/source/contract.d.ts +14 -0
- package/dist/source/contract.d.ts.map +1 -1
- package/dist/source/contract.js +10 -0
- package/dist/source/contract.js.map +1 -1
- package/dist/source/factory.d.ts +7 -1
- package/dist/source/factory.d.ts.map +1 -1
- package/dist/source/factory.js +116 -5
- package/dist/source/factory.js.map +1 -1
- package/dist/source/index.d.ts +2 -1
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/source/migrations.d.ts.map +1 -1
- package/dist/source/migrations.js +21 -0
- package/dist/source/migrations.js.map +1 -1
- package/dist/source/subjectAuthorization.d.ts +16 -0
- package/dist/source/subjectAuthorization.d.ts.map +1 -0
- package/dist/source/subjectAuthorization.js +126 -0
- package/dist/source/subjectAuthorization.js.map +1 -0
- package/dist/source/types.d.ts +52 -2
- package/dist/source/types.d.ts.map +1 -1
- package/dist/source/types.js +1 -0
- package/dist/source/types.js.map +1 -1
- package/dist/syncLog/contract.d.ts +45 -3
- package/dist/syncLog/contract.d.ts.map +1 -1
- package/dist/syncLog/contract.js +46 -4
- package/dist/syncLog/contract.js.map +1 -1
- package/dist/testing/fixtures/httpResponses.d.ts +6 -0
- package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
- package/dist/testing/fixtures/httpResponses.js +1 -0
- package/dist/testing/fixtures/httpResponses.js.map +1 -1
- package/dist/transport/httpClient.d.ts +8 -1
- package/dist/transport/httpClient.d.ts.map +1 -1
- package/dist/transport/httpClient.js +51 -20
- package/dist/transport/httpClient.js.map +1 -1
- package/dist/transport/httpCommitRequest.d.ts +45 -0
- package/dist/transport/httpCommitRequest.d.ts.map +1 -0
- package/dist/transport/httpCommitRequest.js +67 -0
- package/dist/transport/httpCommitRequest.js.map +1 -0
- package/dist/transport/httpTransport.d.ts.map +1 -1
- package/dist/transport/httpTransport.js +69 -106
- package/dist/transport/httpTransport.js.map +1 -1
- package/dist/transport/httpTransportHelpers.d.ts +10 -0
- package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
- package/dist/transport/httpTransportHelpers.js +46 -0
- package/dist/transport/httpTransportHelpers.js.map +1 -0
- package/dist/wire/apiLifecycle.d.ts +63 -0
- package/dist/wire/apiLifecycle.d.ts.map +1 -0
- package/dist/wire/apiLifecycle.js +89 -0
- package/dist/wire/apiLifecycle.js.map +1 -0
- package/dist/wire/auth.d.ts.map +1 -1
- package/dist/wire/auth.js +6 -3
- package/dist/wire/auth.js.map +1 -1
- package/dist/wire/commit.d.ts +74 -9
- package/dist/wire/commit.d.ts.map +1 -1
- package/dist/wire/commit.js +18 -5
- package/dist/wire/commit.js.map +1 -1
- package/dist/wire/index.d.ts +5 -2
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +12 -1
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/modelMutations.js +3 -2
- package/dist/wire/modelMutations.js.map +1 -1
- package/dist/wire/rateLimit.d.ts +82 -0
- package/dist/wire/rateLimit.d.ts.map +1 -0
- package/dist/wire/rateLimit.js +142 -0
- package/dist/wire/rateLimit.js.map +1 -0
- package/package.json +1 -1
- package/src/auth/hostedEndpoints.ts +23 -5
- package/src/auth/index.ts +2 -0
- package/src/coordination/index.ts +0 -2
- package/src/coordination/schema.ts +4 -7
- package/src/errorCodes.ts +6 -0
- package/src/errors.ts +4 -1
- package/src/readSetContext.ts +8 -2
- package/src/resources/httpResources.ts +116 -25
- package/src/resources/modelCreate.ts +73 -0
- package/src/resources/modelOperations.ts +29 -2
- package/src/resources/writeOptionsSchema.ts +30 -2
- package/src/schema/audit.ts +121 -0
- package/src/schema/ddl.ts +71 -1
- package/src/schema/index.ts +16 -0
- package/src/schema/model.ts +17 -0
- package/src/schema/openapi.ts +123 -2
- package/src/schema/roles.ts +53 -0
- package/src/schema/schema.ts +68 -3
- package/src/schema/serialize.ts +8 -1
- package/src/schema/subject.ts +43 -0
- package/src/server/adapter.ts +2 -0
- package/src/server/readConfig.ts +4 -0
- package/src/source/adapters/drizzle.ts +50 -6
- package/src/source/adapters/kysely.ts +47 -4
- package/src/source/adapters/kyselyMutationCore.ts +21 -9
- package/src/source/adapters/memory.ts +1 -0
- package/src/source/adapters/prisma.ts +70 -7
- package/src/source/contract.ts +11 -0
- package/src/source/factory.ts +141 -5
- package/src/source/index.ts +6 -0
- package/src/source/migrations.ts +21 -0
- package/src/source/subjectAuthorization.ts +182 -0
- package/src/source/types.ts +56 -2
- package/src/syncLog/contract.ts +47 -5
- package/src/testing/fixtures/httpResponses.ts +7 -0
- package/src/transport/httpClient.ts +85 -23
- package/src/transport/httpCommitRequest.ts +104 -0
- package/src/transport/httpTransport.ts +92 -142
- package/src/transport/httpTransportHelpers.ts +63 -0
- package/src/wire/apiLifecycle.ts +94 -0
- package/src/wire/auth.ts +6 -3
- package/src/wire/commit.ts +20 -5
- package/src/wire/index.ts +31 -0
- package/src/wire/modelMutations.ts +2 -2
- package/src/wire/rateLimit.ts +155 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Canonical create identity and result correlation shared by every client. */
|
|
2
|
+
|
|
3
|
+
import { v5 as uuidv5 } from 'uuid';
|
|
4
|
+
import { AbloConnectionError } from '../errors.js';
|
|
5
|
+
import type { CommitOperationResult } from '../wire/commit.js';
|
|
6
|
+
|
|
7
|
+
/** Resolve the two supported single-create id spellings. The sibling wins. */
|
|
8
|
+
export function resolveCreateId(
|
|
9
|
+
explicitId: string | null | undefined,
|
|
10
|
+
data: unknown,
|
|
11
|
+
): string | undefined {
|
|
12
|
+
if (typeof explicitId === 'string' && explicitId.length > 0) return explicitId;
|
|
13
|
+
const inData =
|
|
14
|
+
typeof data === 'object' && data !== null
|
|
15
|
+
? (data as { readonly id?: unknown }).id
|
|
16
|
+
: undefined;
|
|
17
|
+
return typeof inData === 'string' && inData.length > 0 ? inData : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stable across transports and client instances when an idempotency key is supplied. */
|
|
21
|
+
export function createModelId(
|
|
22
|
+
modelName: string,
|
|
23
|
+
idempotencyKey?: string | null,
|
|
24
|
+
): string {
|
|
25
|
+
if (idempotencyKey) {
|
|
26
|
+
return uuidv5(
|
|
27
|
+
`${modelName}:${idempotencyKey}`,
|
|
28
|
+
'aa4ba6d4-bf0b-5b38-9c45-116f79a6e548',
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
32
|
+
? crypto.randomUUID()
|
|
33
|
+
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Return server-authored create rows in input order.
|
|
38
|
+
*
|
|
39
|
+
* Fresh receipts carry operationResults. Durable idempotency replays redact
|
|
40
|
+
* row data, so that path verifies every expected id with a point read. It may
|
|
41
|
+
* fail under a write-only policy, but it can never report a partial collection
|
|
42
|
+
* as success.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveCreatedRows<T>(input: {
|
|
45
|
+
readonly modelName: string;
|
|
46
|
+
readonly ids: readonly string[];
|
|
47
|
+
readonly operationResults?: readonly CommitOperationResult[];
|
|
48
|
+
readonly readRow: (id: string) => Promise<T | undefined>;
|
|
49
|
+
}): Promise<T[]> {
|
|
50
|
+
if (input.operationResults?.length === input.ids.length) {
|
|
51
|
+
const byId = new Map(
|
|
52
|
+
input.operationResults.map((result) => [
|
|
53
|
+
(result.row as { readonly id?: unknown }).id,
|
|
54
|
+
result.row,
|
|
55
|
+
]),
|
|
56
|
+
);
|
|
57
|
+
const ordered = input.ids.map((id) => byId.get(id));
|
|
58
|
+
if (ordered.every((row): row is Record<string, unknown> => row !== undefined)) {
|
|
59
|
+
return ordered as T[];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rows = await Promise.all(input.ids.map((id) => input.readRow(id)));
|
|
64
|
+
const missing = input.ids.filter((_id, index) => rows[index] === undefined);
|
|
65
|
+
if (missing.length > 0) {
|
|
66
|
+
throw new AbloConnectionError(
|
|
67
|
+
`${input.modelName} create confirmed, but ${missing.length} of ${input.ids.length} ` +
|
|
68
|
+
'server rows could not be recovered from its receipt or verified by id.',
|
|
69
|
+
{ code: 'commit_no_result', details: { missingIds: missing } },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return rows as T[];
|
|
73
|
+
}
|
|
@@ -15,7 +15,7 @@ import type { ResolveClaimMeta } from '../types/global.js';
|
|
|
15
15
|
import type { AbloError } from '../errors.js';
|
|
16
16
|
import type { StaleNotification, TrackDependency } from '../coordination/schema.js';
|
|
17
17
|
import type { FieldRef, FieldSelector } from '../schema/fieldRef.js';
|
|
18
|
-
import type { BaseModelFields } from '../schema/schema.js';
|
|
18
|
+
import type { BaseModelFields, Clearable } from '../schema/schema.js';
|
|
19
19
|
import type { ClaimHeartbeatPlan } from '../coordination/claimHeartbeatLoop.js';
|
|
20
20
|
import type { Duration } from '../utils/duration.js';
|
|
21
21
|
import type {
|
|
@@ -232,6 +232,14 @@ export interface ServerReadOptions<T> {
|
|
|
232
232
|
expand?: readonly string[];
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/** Options for an explicit complete collection traversal. */
|
|
236
|
+
export interface ListAllOptions<T> extends Omit<ServerReadOptions<T>, 'cursor'> {
|
|
237
|
+
/** Maximum pages to read before refusing an unexpectedly broad traversal. @default 100 */
|
|
238
|
+
maxPages?: number;
|
|
239
|
+
/** Stops traversal between page requests and row yields. */
|
|
240
|
+
signal?: AbortSignal;
|
|
241
|
+
}
|
|
242
|
+
|
|
235
243
|
/** Options for the single-row async server read `get({ id })`. A subset of
|
|
236
244
|
* {@link ServerReadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
|
|
237
245
|
export type ServerRetrieveOptions = Pick<ServerReadOptions<unknown>, 'type' | 'expand'>;
|
|
@@ -588,6 +596,21 @@ export interface ModelCreateParams<T, CreateInput>
|
|
|
588
596
|
readonly claim?: Claim<T> | ClaimTargetOptions<CreateInput> | null;
|
|
589
597
|
}
|
|
590
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Creating many rows at once: the same verb, handed a list.
|
|
601
|
+
*
|
|
602
|
+
* One atomic commit, so the batch lands whole or not at all, and the rows come
|
|
603
|
+
* back in the order they were given. There is no `id` beside `data` here the
|
|
604
|
+
* way there is for a single create, since one id cannot address many rows;
|
|
605
|
+
* write it into each row instead, which the create input has always allowed.
|
|
606
|
+
*/
|
|
607
|
+
export type ModelCreateManyParams<CreateInput> = Pick<
|
|
608
|
+
ModelWriteOptions,
|
|
609
|
+
'idempotencyKey' | 'reads' | 'track'
|
|
610
|
+
> & {
|
|
611
|
+
readonly data: readonly CreateInput[];
|
|
612
|
+
};
|
|
613
|
+
|
|
591
614
|
export interface ModelUpdateParams<T, Fields = T>
|
|
592
615
|
extends ModelWriteOptions {
|
|
593
616
|
readonly id: string;
|
|
@@ -595,8 +618,12 @@ export interface ModelUpdateParams<T, Fields = T>
|
|
|
595
618
|
* Patch only fields declared by the model's Zod input shape. Hydrated rows
|
|
596
619
|
* also carry framework fields, relations, methods, and computed values; none
|
|
597
620
|
* of those are writable data.
|
|
621
|
+
*
|
|
622
|
+
* Send a field to change it, omit it to leave it, send `null` to clear it —
|
|
623
|
+
* see {@link Clearable}. `undefined` is not a clear: it is dropped from the
|
|
624
|
+
* payload and the old value survives.
|
|
598
625
|
*/
|
|
599
|
-
readonly data:
|
|
626
|
+
readonly data: Clearable<ClaimableFields<Fields>>;
|
|
600
627
|
readonly claim?: Claim<T> | ClaimTargetOptions<Fields> | null;
|
|
601
628
|
}
|
|
602
629
|
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { z } from 'zod';
|
|
20
|
+
import { logPositionSchema } from '../syncLog/contract.js';
|
|
20
21
|
import type { MutationOptions } from '../resources/mutationOptions.js';
|
|
21
22
|
import { AbloValidationError } from '../errors.js';
|
|
22
23
|
import { commitWaitSchema } from '../wire/commit.js';
|
|
@@ -24,7 +25,6 @@ import {
|
|
|
24
25
|
onStaleModeSchema,
|
|
25
26
|
MAX_READ_SET_ENTRIES,
|
|
26
27
|
readDependencyListSchema,
|
|
27
|
-
readSetWatermarkSchema,
|
|
28
28
|
readSetProjectionEntryCount,
|
|
29
29
|
trackDependencyListSchema,
|
|
30
30
|
} from '../coordination/schema.js';
|
|
@@ -46,7 +46,7 @@ export const writeOptionsSchema = z.object({
|
|
|
46
46
|
/** Resolve when queued locally (default) or once the server confirms. */
|
|
47
47
|
wait: commitWaitSchema.optional(),
|
|
48
48
|
/** Stale guard: the sync watermark the caller's reasoning was based on. */
|
|
49
|
-
readAt:
|
|
49
|
+
readAt: logPositionSchema.nullish(),
|
|
50
50
|
/** What the server does when the target moved past `readAt`. */
|
|
51
51
|
onStale: onStaleModeSchema.nullish(),
|
|
52
52
|
/** The held claim's fencing token (Option B), sourced from the claim handle
|
|
@@ -107,3 +107,31 @@ const _writeOptionsContractInSync: AssertExact<
|
|
|
107
107
|
keyof WriteOptionsContract
|
|
108
108
|
> = true;
|
|
109
109
|
void _writeOptionsContractInSync;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Refuse a per-model write that does not name its row.
|
|
113
|
+
*
|
|
114
|
+
* `update` and `delete` address the row through the URL, so an absent `id`
|
|
115
|
+
* used to be spelled into the path by `encodeURIComponent` as the literal
|
|
116
|
+
* string `"undefined"`. The request was well-formed, it matched no row, and it
|
|
117
|
+
* came back as an ordinary receipt: `delete({ where: { id } })` reported
|
|
118
|
+
* success and deleted nothing. `{ where }` is the shape the commit protocol
|
|
119
|
+
* takes one layer down, so reaching for it here is an easy and quiet mistake.
|
|
120
|
+
*
|
|
121
|
+
* The typed surface already rejects it at compile time. This is the same
|
|
122
|
+
* refusal for callers who reach the transport without those types.
|
|
123
|
+
*/
|
|
124
|
+
export function assertWriteTarget(
|
|
125
|
+
action: 'update' | 'delete',
|
|
126
|
+
modelName: string,
|
|
127
|
+
id: unknown,
|
|
128
|
+
): void {
|
|
129
|
+
if (typeof id === 'string' && id.length > 0) return;
|
|
130
|
+
const namedAFilter =
|
|
131
|
+
typeof id === 'object' && id !== null && 'where' in (id as Record<string, unknown>);
|
|
132
|
+
throw new AbloValidationError(
|
|
133
|
+
`A ${modelName} ${action} has to name the row it acts on: ${action}({ id })` +
|
|
134
|
+
(namedAFilter ? ', not a `where` filter.' : '.'),
|
|
135
|
+
{ code: 'invalid_body', param: 'id' },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ModelJSON, SchemaJSON } from './serialize.js';
|
|
2
|
+
import { resolveTenancy } from './tenancy.js';
|
|
3
|
+
|
|
4
|
+
export interface SchemaAuditFinding {
|
|
5
|
+
readonly code: 'scope_routing_without_access_policy';
|
|
6
|
+
readonly severity: 'error';
|
|
7
|
+
readonly model: string;
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly fix: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface RoutingAnchor {
|
|
13
|
+
readonly description: string;
|
|
14
|
+
readonly column: string;
|
|
15
|
+
readonly parentTable?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function tableName(key: string, model: ModelJSON): string {
|
|
19
|
+
return model.tableName ?? key;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fieldColumn(model: ModelJSON, field: string): string {
|
|
23
|
+
return model.fields[field]?.column ?? field;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function camelToSnake(value: string): string {
|
|
27
|
+
return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function reachesScope(
|
|
31
|
+
key: string,
|
|
32
|
+
models: SchemaJSON['models'],
|
|
33
|
+
seen: ReadonlySet<string> = new Set(),
|
|
34
|
+
): boolean {
|
|
35
|
+
if (seen.has(key)) return false;
|
|
36
|
+
const model = models[key];
|
|
37
|
+
if (!model) return false;
|
|
38
|
+
if (model.scope) return true;
|
|
39
|
+
const nextSeen = new Set(seen).add(key);
|
|
40
|
+
return Object.values(model.relations).some(
|
|
41
|
+
(relation) => relation.type === 'belongsTo' && reachesScope(relation.target, models, nextSeen),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function routingAnchors(
|
|
46
|
+
model: ModelJSON,
|
|
47
|
+
models: SchemaJSON['models'],
|
|
48
|
+
): readonly RoutingAnchor[] {
|
|
49
|
+
const anchors: RoutingAnchor[] = [];
|
|
50
|
+
|
|
51
|
+
if (model.scope) {
|
|
52
|
+
anchors.push({ description: `scope root ${String(model.scope)}`, column: 'id' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const role of model.entityRoles ?? []) {
|
|
56
|
+
anchors.push({
|
|
57
|
+
description: `entity role ${role.kind}`,
|
|
58
|
+
column: fieldColumn(model, role.source.field),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const [relationName, relation] of Object.entries(model.relations)) {
|
|
63
|
+
if (relation.type !== 'belongsTo' || !reachesScope(relation.target, models)) continue;
|
|
64
|
+
const target = models[relation.target];
|
|
65
|
+
if (!target) continue;
|
|
66
|
+
anchors.push({
|
|
67
|
+
description: `parent scope ${relationName}`,
|
|
68
|
+
column: relation.foreignKeyColumn,
|
|
69
|
+
parentTable: tableName(relation.target, target),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return anchors;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function policyMatches(anchor: RoutingAnchor, model: ModelJSON): boolean {
|
|
77
|
+
const tenancy = resolveTenancy(model);
|
|
78
|
+
if (tenancy.kind === 'source') return true;
|
|
79
|
+
if (tenancy.kind === 'column') {
|
|
80
|
+
return tenancy.column === anchor.column || tenancy.column === camelToSnake(anchor.column);
|
|
81
|
+
}
|
|
82
|
+
if (tenancy.kind === 'parent') {
|
|
83
|
+
return tenancy.via.localKey === anchor.column &&
|
|
84
|
+
(anchor.parentTable === undefined || tenancy.via.parentTable === anchor.parentTable);
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Finds mutable models whose sync-group routing is narrower than their row-read
|
|
91
|
+
* policy. A correctly filtered list or routed stream is not evidence that a
|
|
92
|
+
* foreign row is unreadable; only the policy is the authorization boundary.
|
|
93
|
+
*/
|
|
94
|
+
export function auditSchemaAccessPolicies(schema: SchemaJSON): readonly SchemaAuditFinding[] {
|
|
95
|
+
const findings: SchemaAuditFinding[] = [];
|
|
96
|
+
|
|
97
|
+
for (const [key, model] of Object.entries(schema.models)) {
|
|
98
|
+
if (model.mutable === false || model.routingOnly === true) continue;
|
|
99
|
+
const mismatched = routingAnchors(model, schema.models)
|
|
100
|
+
.filter((anchor) => !policyMatches(anchor, model));
|
|
101
|
+
if (mismatched.length === 0) continue;
|
|
102
|
+
|
|
103
|
+
const policy = resolveTenancy(model);
|
|
104
|
+
const routedBy = mismatched.map(({ description, column }) => `${description} (${column})`).join(', ');
|
|
105
|
+
findings.push({
|
|
106
|
+
code: 'scope_routing_without_access_policy',
|
|
107
|
+
severity: 'error',
|
|
108
|
+
model: key,
|
|
109
|
+
message:
|
|
110
|
+
`Model "${key}" routes changes by ${routedBy}, but its row-access policy is ` +
|
|
111
|
+
`by ${policy.kind}. Sync groups route delivery; they do not authorize reads. ` +
|
|
112
|
+
`A filtered list or correctly routed live stream is not an authorization proof.`,
|
|
113
|
+
fix:
|
|
114
|
+
`Make policy follow the same customer/workspace boundary on every reachable model, ` +
|
|
115
|
+
`use policy: { by: 'source' } with one Ablo organization per hard tenant, or set ` +
|
|
116
|
+
`groups.routingOnly: true to explicitly acknowledge an intentionally broader read policy.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return findings;
|
|
121
|
+
}
|
package/src/schema/ddl.ts
CHANGED
|
@@ -27,6 +27,7 @@ import type { SchemaJSON, ModelJSON } from './serialize.js';
|
|
|
27
27
|
import type { MigrationStep, BackfillValue, FieldType } from './diff.js';
|
|
28
28
|
import type { FieldMeta } from './field.js';
|
|
29
29
|
import { resolveTenancy, tenancyColumn } from './tenancy.js';
|
|
30
|
+
import type { SubjectRule } from './subject.js';
|
|
30
31
|
|
|
31
32
|
export interface ProvisionPlan {
|
|
32
33
|
/** The Postgres schema the tables live in (`app_<id>` or `public`). */
|
|
@@ -119,6 +120,22 @@ export function sqlType(fieldType: ModelJSON['fields'][string]['type']): string
|
|
|
119
120
|
|
|
120
121
|
const BASE_COLUMNS = new Set(['id']);
|
|
121
122
|
|
|
123
|
+
function subjectPredicate(model: ModelJSON, rule: SubjectRule): string {
|
|
124
|
+
const meta = model.fields[rule.field];
|
|
125
|
+
if (!meta) {
|
|
126
|
+
throw new AbloValidationError(`subject field ${JSON.stringify(rule.field)} is not declared`, {
|
|
127
|
+
code: 'schema_definition_invalid',
|
|
128
|
+
param: 'subject.field',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const column = meta.column ?? camelToSnake(rule.field);
|
|
132
|
+
const prefix = `${rule.group}:`.replace(/'/g, "''");
|
|
133
|
+
return (
|
|
134
|
+
`COALESCE(NULLIF(current_setting('app.current_subject_groups', true), ''), '[]')::jsonb ` +
|
|
135
|
+
`? ('${prefix}' || ${q(column)}::text)`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
// ── Foreign keys (relation-driven, sync-safe) ────────────────────────────────
|
|
123
140
|
|
|
124
141
|
/**
|
|
@@ -324,7 +341,20 @@ export function generateProvisionPlan(
|
|
|
324
341
|
statements.push(`ALTER TABLE ${qt} ENABLE ROW LEVEL SECURITY;`);
|
|
325
342
|
statements.push(`ALTER TABLE ${qt} FORCE ROW LEVEL SECURITY;`);
|
|
326
343
|
const policy = `${table}_tenant_isolation`;
|
|
327
|
-
const
|
|
344
|
+
const predicates = [
|
|
345
|
+
`${q(orgCol)} = current_setting('app.current_org_id', true)`,
|
|
346
|
+
...(model.subject ? [subjectPredicate(model, model.subject)] : []),
|
|
347
|
+
];
|
|
348
|
+
const predicate = predicates.length === 1
|
|
349
|
+
? predicates[0]!
|
|
350
|
+
: predicates.map((part) => `(${part})`).join(' AND ');
|
|
351
|
+
statements.push(`DROP POLICY IF EXISTS ${q(policy)} ON ${qt};`);
|
|
352
|
+
statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
|
|
353
|
+
} else if (model.subject) {
|
|
354
|
+
statements.push(`ALTER TABLE ${qt} ENABLE ROW LEVEL SECURITY;`);
|
|
355
|
+
statements.push(`ALTER TABLE ${qt} FORCE ROW LEVEL SECURITY;`);
|
|
356
|
+
const policy = `${table}_subject_isolation`;
|
|
357
|
+
const predicate = subjectPredicate(model, model.subject);
|
|
328
358
|
statements.push(`DROP POLICY IF EXISTS ${q(policy)} ON ${qt};`);
|
|
329
359
|
statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
|
|
330
360
|
}
|
|
@@ -585,5 +615,45 @@ export function generateMigrationPlan(
|
|
|
585
615
|
}
|
|
586
616
|
}
|
|
587
617
|
|
|
618
|
+
// Subject authorization is metadata rather than a field diff. Reconcile when
|
|
619
|
+
// that metadata changes, or when the protected field's physical column
|
|
620
|
+
// changes. An identical empty migration must remain a true no-op.
|
|
621
|
+
for (const [key, model] of Object.entries(next.models)) {
|
|
622
|
+
if ((model.plane ?? 'tenant') === 'control') continue;
|
|
623
|
+
const previousModel = prev?.models[key];
|
|
624
|
+
const subjectChanged = JSON.stringify(previousModel?.subject) !== JSON.stringify(model.subject);
|
|
625
|
+
const subjectColumnChanged = steps.some((step) =>
|
|
626
|
+
'model' in step && step.model === key &&
|
|
627
|
+
step.kind === 'alter_field' &&
|
|
628
|
+
(step.field === model.subject?.field || step.field === previousModel?.subject?.field) &&
|
|
629
|
+
step.changes.column !== undefined
|
|
630
|
+
);
|
|
631
|
+
if (!subjectChanged && !subjectColumnChanged) continue;
|
|
632
|
+
const table = model.tableName ?? key;
|
|
633
|
+
const qt = qtFor(table);
|
|
634
|
+
const orgCol = tenancyColumn(resolveTenancy(model));
|
|
635
|
+
const tenantPolicy = `${table}_tenant_isolation`;
|
|
636
|
+
const subjectPolicy = `${table}_subject_isolation`;
|
|
637
|
+
if (orgCol || model.subject) {
|
|
638
|
+
statements.push(`ALTER TABLE ${qt} ENABLE ROW LEVEL SECURITY;`);
|
|
639
|
+
statements.push(`ALTER TABLE ${qt} FORCE ROW LEVEL SECURITY;`);
|
|
640
|
+
statements.push(`DROP POLICY IF EXISTS ${q(tenantPolicy)} ON ${qt};`);
|
|
641
|
+
statements.push(`DROP POLICY IF EXISTS ${q(subjectPolicy)} ON ${qt};`);
|
|
642
|
+
const parts = [
|
|
643
|
+
...(orgCol ? [`${q(orgCol)} = current_setting('app.current_org_id', true)`] : []),
|
|
644
|
+
...(model.subject ? [subjectPredicate(model, model.subject)] : []),
|
|
645
|
+
];
|
|
646
|
+
const predicate = parts.length === 1
|
|
647
|
+
? parts[0]!
|
|
648
|
+
: parts.map((part) => `(${part})`).join(' AND ');
|
|
649
|
+
const policy = orgCol ? tenantPolicy : subjectPolicy;
|
|
650
|
+
statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
|
|
651
|
+
} else if (previousModel?.subject) {
|
|
652
|
+
statements.push(`DROP POLICY IF EXISTS ${q(subjectPolicy)} ON ${qt};`);
|
|
653
|
+
statements.push(`ALTER TABLE ${qt} NO FORCE ROW LEVEL SECURITY;`);
|
|
654
|
+
statements.push(`ALTER TABLE ${qt} DISABLE ROW LEVEL SECURITY;`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
588
658
|
return { appSchema: targetSchema, statements, concurrent };
|
|
589
659
|
}
|
package/src/schema/index.ts
CHANGED
|
@@ -154,12 +154,16 @@ export {
|
|
|
154
154
|
type InsertValue,
|
|
155
155
|
type UpsertValue,
|
|
156
156
|
type UpdateValue,
|
|
157
|
+
type Clearable,
|
|
157
158
|
type DeleteId,
|
|
158
159
|
type DefineSchemaOptions,
|
|
159
160
|
type Casing,
|
|
160
161
|
type CasingConvention,
|
|
161
162
|
type CasingFn,
|
|
162
163
|
composeEntitySyncGroups,
|
|
164
|
+
syncGroupsForRow,
|
|
165
|
+
InvalidRecordSubjectError,
|
|
166
|
+
type RecordSyncGroupSpec,
|
|
163
167
|
intersectRequestedWithAllowed,
|
|
164
168
|
type IdentityRole,
|
|
165
169
|
type IdentityContext,
|
|
@@ -210,6 +214,18 @@ export {
|
|
|
210
214
|
type RelationJSON,
|
|
211
215
|
} from './serialize.js';
|
|
212
216
|
|
|
217
|
+
export {
|
|
218
|
+
auditSchemaAccessPolicies,
|
|
219
|
+
type SchemaAuditFinding,
|
|
220
|
+
} from './audit.js';
|
|
221
|
+
|
|
222
|
+
export {
|
|
223
|
+
subjectRuleSchema,
|
|
224
|
+
subjectGroupForRow,
|
|
225
|
+
subjectAuthorized,
|
|
226
|
+
type SubjectRule,
|
|
227
|
+
} from './subject.js';
|
|
228
|
+
|
|
213
229
|
// Schema projection — derive an app's subset from one canonical schema.
|
|
214
230
|
export { selectModels, omitModels, omittedModelError } from './select.js';
|
|
215
231
|
|
package/src/schema/model.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { z } from 'zod';
|
|
22
22
|
import type { RelationDef } from './relation.js';
|
|
23
23
|
import type { EntityRole, GroupsInput } from './roles.js';
|
|
24
|
+
import type { SubjectRule } from './subject.js';
|
|
24
25
|
import { getFieldMeta, inferFieldMetaFromZod, type FieldMeta } from './field.js';
|
|
25
26
|
// Tenancy lives in `tenancy.ts`. Authoring uses the `policy` option
|
|
26
27
|
// (`PolicyInput`), which `resolvePolicy` normalizes into the canonical `Tenancy`
|
|
@@ -152,6 +153,14 @@ export interface ModelOptions {
|
|
|
152
153
|
*/
|
|
153
154
|
policy?: PolicyInput;
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Credential-bound row authorization below organization tenancy. The named
|
|
158
|
+
* row field must match a trusted credential group: `{ field: 'workspaceId',
|
|
159
|
+
* group: 'workspace' }` authorizes only rows whose `workspaceId` has a
|
|
160
|
+
* matching `workspace:<id>` group on the request.
|
|
161
|
+
*/
|
|
162
|
+
subject?: SubjectRule;
|
|
163
|
+
|
|
155
164
|
/**
|
|
156
165
|
* Which database a model's rows live in. `tenant` (the default) is tenant data
|
|
157
166
|
* that provisioning places in the customer's own database; `control` is Ablo's own
|
|
@@ -176,6 +185,8 @@ export interface ModelOptions {
|
|
|
176
185
|
* - `roles` — explicit record-to-group roles keyed on a plain field, for routing
|
|
177
186
|
* that does not follow a relation, such as fanning a message into a recipient's
|
|
178
187
|
* inbox. Accepts one role or many.
|
|
188
|
+
* - `routingOnly: true` — explicitly acknowledges that these delivery groups
|
|
189
|
+
* are intentionally narrower than the model's independently safe read policy.
|
|
179
190
|
*
|
|
180
191
|
* ```ts
|
|
181
192
|
* // archiveMember: { userId, archiveId }
|
|
@@ -353,6 +364,8 @@ export interface ModelDef<
|
|
|
353
364
|
/** The canonical tenancy descriptor for this model, normalized from the `policy`
|
|
354
365
|
* option at build time. See {@link ModelOptions.policy}. */
|
|
355
366
|
readonly tenancy: Tenancy;
|
|
367
|
+
/** Credential-bound row authorization. See {@link ModelOptions.subject}. */
|
|
368
|
+
readonly subject?: SubjectRule;
|
|
356
369
|
/** Which database this model's rows live in — `tenant` (default) can be a
|
|
357
370
|
* customer's own database; `control` is Ablo's. See {@link ModelOptions.plane}. */
|
|
358
371
|
readonly plane?: ModelResidency;
|
|
@@ -362,6 +375,8 @@ export interface ModelDef<
|
|
|
362
375
|
readonly grants?: GrantsRef;
|
|
363
376
|
/** Explicit record-to-group roles, normalized to an array. See {@link ModelOptions.groups}. */
|
|
364
377
|
readonly entityRoles?: readonly EntityRole[];
|
|
378
|
+
/** Explicit acknowledgement that sync groups are routing, not row access. */
|
|
379
|
+
readonly routingOnly?: true;
|
|
365
380
|
/** The write-conflict disposition per committer kind, carried as plain data. See
|
|
366
381
|
* {@link ModelOptions.conflict}. */
|
|
367
382
|
readonly conflict?: ConflictAxis;
|
|
@@ -445,11 +460,13 @@ export function model<
|
|
|
445
460
|
// Normalize the `policy` option into the canonical tenancy descriptor (defaults
|
|
446
461
|
// to a row-local organization column).
|
|
447
462
|
tenancy: resolvePolicy(options?.policy),
|
|
463
|
+
subject: options?.subject,
|
|
448
464
|
plane: options?.plane ?? DEFAULT_RESIDENCY,
|
|
449
465
|
// Unpack the `groups` option into the individual routing fields the server reads.
|
|
450
466
|
scope: options?.groups?.root,
|
|
451
467
|
grants: options?.groups?.grants,
|
|
452
468
|
entityRoles: normalizeEntityRoles(options?.groups?.roles),
|
|
469
|
+
routingOnly: options?.groups?.routingOnly,
|
|
453
470
|
// The conflict disposition is already plain data, so it passes through unchanged.
|
|
454
471
|
conflict: options?.conflict,
|
|
455
472
|
mutable: options?.mutable ?? true,
|
package/src/schema/openapi.ts
CHANGED
|
@@ -53,6 +53,20 @@ import {
|
|
|
53
53
|
claimReleaseReplySchema,
|
|
54
54
|
} from '../wire/claims.js';
|
|
55
55
|
import { errorEnvelopeSchema } from '../wire/errorEnvelope.js';
|
|
56
|
+
// The lifecycle promise and the rate-limit fields are contract, not prose about
|
|
57
|
+
// contract: the document renders the same constants the server emits, so the
|
|
58
|
+
// published policy cannot describe a signal the runtime does not send.
|
|
59
|
+
import {
|
|
60
|
+
API_DEPRECATION_HEADER,
|
|
61
|
+
API_LIFECYCLE,
|
|
62
|
+
API_SUNSET_HEADER,
|
|
63
|
+
API_VERSION_HEADER,
|
|
64
|
+
} from '../wire/apiLifecycle.js';
|
|
65
|
+
import {
|
|
66
|
+
RATE_LIMIT_HEADER,
|
|
67
|
+
RATE_LIMIT_POLICY_HEADER,
|
|
68
|
+
RETRY_AFTER_HEADER,
|
|
69
|
+
} from '../wire/rateLimit.js';
|
|
56
70
|
import { modelReadResponseSchema, modelListResponseSchema } from '../wire/modelResponses.js';
|
|
57
71
|
import { modelMutationRequestSchema } from '../wire/modelMutations.js';
|
|
58
72
|
import { logListResponseSchema, logQuerySchema } from '../wire/feedEvent.js';
|
|
@@ -400,6 +414,106 @@ const claimIdParam = (): Json => ({
|
|
|
400
414
|
*/
|
|
401
415
|
const commitBody = (): Json => jsonBody(derive(commitRequestSchema, 'input'));
|
|
402
416
|
|
|
417
|
+
/**
|
|
418
|
+
* The response headers every operation carries, declared once under
|
|
419
|
+
* `components/headers` and referenced from each response.
|
|
420
|
+
*
|
|
421
|
+
* A header a caller is expected to act on has to be IN the document to be
|
|
422
|
+
* actionable: a generated client surfaces what the spec declares and drops what
|
|
423
|
+
* it does not, so an undeclared `RateLimit` is a header the caller never sees
|
|
424
|
+
* and therefore never paces against. Declaring them here rather than at each
|
|
425
|
+
* response is what keeps one description of each — the reason the names are
|
|
426
|
+
* imported rather than typed out.
|
|
427
|
+
*/
|
|
428
|
+
const RESPONSE_HEADER_COMPONENTS: Readonly<Record<string, Json>> = {
|
|
429
|
+
AbloVersion: {
|
|
430
|
+
description:
|
|
431
|
+
'The date-stamped contract version this response was served under. Record ' +
|
|
432
|
+
'the value your integration was built against; a change means something ' +
|
|
433
|
+
'observable was added.',
|
|
434
|
+
schema: { type: 'string', examples: ['2026-08-15'] },
|
|
435
|
+
},
|
|
436
|
+
RateLimitPolicy: {
|
|
437
|
+
description:
|
|
438
|
+
'The standing allowance for this credential kind, as a Structured Fields ' +
|
|
439
|
+
'List: `"secret";q=600;w=12`. It does not move between responses, so read ' +
|
|
440
|
+
'it once and pace against it.',
|
|
441
|
+
schema: { type: 'string', examples: ['"secret";q=600;w=12'] },
|
|
442
|
+
},
|
|
443
|
+
RateLimit: {
|
|
444
|
+
description:
|
|
445
|
+
'This caller\u2019s live position in the allowance: `r` units left, `t` ' +
|
|
446
|
+
'seconds until they refill. Present once the request is attributed to a ' +
|
|
447
|
+
'credential.',
|
|
448
|
+
schema: { type: 'string', examples: ['"secret";r=412;t=8'] },
|
|
449
|
+
},
|
|
450
|
+
RetryAfter: {
|
|
451
|
+
description: 'Whole seconds to wait before retrying. Sent with 429 and 503.',
|
|
452
|
+
schema: { type: 'integer', minimum: 1, examples: [8] },
|
|
453
|
+
},
|
|
454
|
+
RequestId: {
|
|
455
|
+
description:
|
|
456
|
+
'Correlation id for this request, repeated in the error envelope\u2019s ' +
|
|
457
|
+
'`request_id`. Quote it in a support request.',
|
|
458
|
+
schema: { type: 'string', examples: ['req_52bb7f46-17bc-4f2d-9988-89d1398d2990'] },
|
|
459
|
+
},
|
|
460
|
+
Deprecation: {
|
|
461
|
+
description:
|
|
462
|
+
'Present only on a route being withdrawn: an sf-Date of when the ' +
|
|
463
|
+
'deprecation took effect (RFC 9745). The route still answers normally.',
|
|
464
|
+
schema: { type: 'string', examples: ['@1774483200'] },
|
|
465
|
+
},
|
|
466
|
+
Sunset: {
|
|
467
|
+
description:
|
|
468
|
+
'Present only on a route being withdrawn: the HTTP-date it stops ' +
|
|
469
|
+
'answering (RFC 8594). Never less than the published notice window after ' +
|
|
470
|
+
`\`${API_DEPRECATION_HEADER}\`.`,
|
|
471
|
+
schema: { type: 'string', examples: ['Tue, 08 Sep 2026 00:00:00 GMT'] },
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const headerRef = (name: string): Json => ({ $ref: `#/components/headers/${name}` });
|
|
476
|
+
|
|
477
|
+
/** Carried by every response, whatever its status. */
|
|
478
|
+
const UNIVERSAL_RESPONSE_HEADERS: Readonly<Record<string, Json>> = {
|
|
479
|
+
[API_VERSION_HEADER]: headerRef('AbloVersion'),
|
|
480
|
+
'X-Request-Id': headerRef('RequestId'),
|
|
481
|
+
[RATE_LIMIT_POLICY_HEADER]: headerRef('RateLimitPolicy'),
|
|
482
|
+
[RATE_LIMIT_HEADER]: headerRef('RateLimit'),
|
|
483
|
+
[API_DEPRECATION_HEADER]: headerRef('Deprecation'),
|
|
484
|
+
[API_SUNSET_HEADER]: headerRef('Sunset'),
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/** Statuses where a wait is what resolves the failure, so `Retry-After` is sent. */
|
|
488
|
+
const RETRY_AFTER_STATUSES = new Set(['429', '503']);
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Stamp the documented headers onto every response of every operation.
|
|
492
|
+
*
|
|
493
|
+
* Runs after the responses are built — including the canonical error ones — so
|
|
494
|
+
* a response added later cannot quietly ship without them.
|
|
495
|
+
*/
|
|
496
|
+
function attachResponseHeaders(paths: Json): void {
|
|
497
|
+
for (const rawPathItem of Object.values(paths)) {
|
|
498
|
+
const pathItem = rawPathItem as Json;
|
|
499
|
+
for (const [method, rawOperation] of Object.entries(pathItem)) {
|
|
500
|
+
if (!HTTP_METHODS.has(method)) continue;
|
|
501
|
+
const operation = rawOperation as Json;
|
|
502
|
+
const responses = (operation.responses ?? {}) as Json;
|
|
503
|
+
for (const [status, rawResponse] of Object.entries(responses)) {
|
|
504
|
+
const response = rawResponse as Json;
|
|
505
|
+
response.headers = {
|
|
506
|
+
...UNIVERSAL_RESPONSE_HEADERS,
|
|
507
|
+
...(RETRY_AFTER_STATUSES.has(status)
|
|
508
|
+
? { [RETRY_AFTER_HEADER]: headerRef('RetryAfter') }
|
|
509
|
+
: {}),
|
|
510
|
+
...((response.headers as Json | undefined) ?? {}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
403
517
|
/** The envelope shared by both specs — same server, same auth, same version. */
|
|
404
518
|
function envelope(options: SchemaToOpenApiOptions, description: string, paths: Json, schemas: Record<string, Json>): Json {
|
|
405
519
|
return {
|
|
@@ -407,7 +521,11 @@ function envelope(options: SchemaToOpenApiOptions, description: string, paths: J
|
|
|
407
521
|
info: {
|
|
408
522
|
title: options.title ?? 'Ablo API',
|
|
409
523
|
version: options.version ?? 'development',
|
|
410
|
-
|
|
524
|
+
// The lifecycle policy travels WITH the routes it governs. An agent
|
|
525
|
+
// deciding whether to integrate needs to know the surface will not move
|
|
526
|
+
// under it, and a policy published somewhere else is one it has to go
|
|
527
|
+
// find — so the document that describes the calls describes the promise.
|
|
528
|
+
description: `${description}\n\n${API_LIFECYCLE}`,
|
|
411
529
|
license: {
|
|
412
530
|
name: 'Apache License 2.0',
|
|
413
531
|
identifier: 'Apache-2.0',
|
|
@@ -417,8 +535,9 @@ function envelope(options: SchemaToOpenApiOptions, description: string, paths: J
|
|
|
417
535
|
security: [{ bearerAuth: [] }],
|
|
418
536
|
components: {
|
|
419
537
|
securitySchemes: {
|
|
420
|
-
bearerAuth: { type: 'http', scheme: 'bearer', description: 'Your Ablo API key (sk_
|
|
538
|
+
bearerAuth: { type: 'http', scheme: 'bearer', description: 'Your Ablo API key (sk_\u2026 / rk_\u2026).' },
|
|
421
539
|
},
|
|
540
|
+
headers: { ...RESPONSE_HEADER_COMPONENTS },
|
|
422
541
|
schemas,
|
|
423
542
|
},
|
|
424
543
|
paths,
|
|
@@ -950,6 +1069,7 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
|
|
|
950
1069
|
|
|
951
1070
|
applyOperationIds(paths, ABLO_OPERATION_IDS);
|
|
952
1071
|
attachCanonicalErrors(paths);
|
|
1072
|
+
attachResponseHeaders(paths);
|
|
953
1073
|
|
|
954
1074
|
return envelope(
|
|
955
1075
|
options,
|
|
@@ -1078,6 +1198,7 @@ export function schemaToOpenApi<S extends SchemaRecord>(
|
|
|
1078
1198
|
operationIds['GET /v1/commits/{id}'] = 'getCommit';
|
|
1079
1199
|
applyOperationIds(paths, operationIds);
|
|
1080
1200
|
attachCanonicalErrors(paths);
|
|
1201
|
+
attachResponseHeaders(paths);
|
|
1081
1202
|
|
|
1082
1203
|
Object.assign(schemas, abloComponentSchemas());
|
|
1083
1204
|
|