@opendatalabs/personal-server-ts-server 1.33.8 → 1.34.1
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/dist/__fixtures__/bound-record-store.d.ts +19 -0
- package/dist/__fixtures__/bound-record-store.d.ts.map +1 -0
- package/dist/__fixtures__/bound-record-store.js +60 -0
- package/dist/__fixtures__/bound-record-store.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts +41 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js +34 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts +112 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js +226 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts +135 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js +21 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js.map +1 -0
- package/dist/api-auth.d.ts +18 -2
- package/dist/api-auth.d.ts.map +1 -1
- package/dist/api-auth.js +114 -22
- package/dist/api-auth.js.map +1 -1
- package/dist/app.d.ts +60 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +139 -1
- package/dist/app.js.map +1 -1
- package/dist/bootstrap.d.ts +10 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +359 -8
- package/dist/bootstrap.js.map +1 -1
- package/dist/enclave-main.d.ts +4 -0
- package/dist/enclave-main.d.ts.map +1 -1
- package/dist/enclave-main.js +152 -1
- package/dist/enclave-main.js.map +1 -1
- package/dist/middleware/body-limit.d.ts +1 -1
- package/dist/middleware/body-limit.d.ts.map +1 -1
- package/dist/middleware/body-limit.js +8 -3
- package/dist/middleware/body-limit.js.map +1 -1
- package/dist/middleware/cors.d.ts.map +1 -1
- package/dist/middleware/cors.js +1 -0
- package/dist/middleware/cors.js.map +1 -1
- package/dist/package.json +9 -1
- package/dist/pdpp/bootstrap.d.ts +72 -0
- package/dist/pdpp/bootstrap.d.ts.map +1 -0
- package/dist/pdpp/bootstrap.js +270 -0
- package/dist/pdpp/bootstrap.js.map +1 -0
- package/dist/pdpp/chain-enforcement.d.ts +95 -0
- package/dist/pdpp/chain-enforcement.d.ts.map +1 -0
- package/dist/pdpp/chain-enforcement.js +145 -0
- package/dist/pdpp/chain-enforcement.js.map +1 -0
- package/dist/pdpp/client-document-fetch.d.ts +63 -0
- package/dist/pdpp/client-document-fetch.d.ts.map +1 -0
- package/dist/pdpp/client-document-fetch.js +254 -0
- package/dist/pdpp/client-document-fetch.js.map +1 -0
- package/dist/pdpp/declaration-registry.d.ts +83 -0
- package/dist/pdpp/declaration-registry.d.ts.map +1 -0
- package/dist/pdpp/declaration-registry.js +251 -0
- package/dist/pdpp/declaration-registry.js.map +1 -0
- package/dist/pdpp/deployment.d.ts +70 -0
- package/dist/pdpp/deployment.d.ts.map +1 -0
- package/dist/pdpp/deployment.js +104 -0
- package/dist/pdpp/deployment.js.map +1 -0
- package/dist/pdpp/record-schema.d.ts +2 -0
- package/dist/pdpp/record-schema.d.ts.map +1 -0
- package/dist/pdpp/record-schema.js +2 -0
- package/dist/pdpp/record-schema.js.map +1 -0
- package/dist/pdpp/records-bootstrap.d.ts +111 -0
- package/dist/pdpp/records-bootstrap.d.ts.map +1 -0
- package/dist/pdpp/records-bootstrap.js +353 -0
- package/dist/pdpp/records-bootstrap.js.map +1 -0
- package/dist/pdpp/source-id-compat.d.ts +5 -0
- package/dist/pdpp/source-id-compat.d.ts.map +1 -0
- package/dist/pdpp/source-id-compat.js +16 -0
- package/dist/pdpp/source-id-compat.js.map +1 -0
- package/dist/pdpp/test-connections.d.ts +6 -0
- package/dist/pdpp/test-connections.d.ts.map +1 -0
- package/dist/pdpp/test-connections.js +37 -0
- package/dist/pdpp/test-connections.js.map +1 -0
- package/dist/routes/auth-device.d.ts.map +1 -1
- package/dist/routes/auth-device.js +3 -0
- package/dist/routes/auth-device.js.map +1 -1
- package/dist/routes/chunked-scope-import.d.ts +41 -0
- package/dist/routes/chunked-scope-import.d.ts.map +1 -0
- package/dist/routes/chunked-scope-import.js +901 -0
- package/dist/routes/chunked-scope-import.js.map +1 -0
- package/dist/routes/data.d.ts +23 -1
- package/dist/routes/data.d.ts.map +1 -1
- package/dist/routes/data.js +112 -1
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/grants.d.ts.map +1 -1
- package/dist/routes/grants.js +2 -0
- package/dist/routes/grants.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +3 -0
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/pdpp-auth.d.ts +157 -0
- package/dist/routes/pdpp-auth.d.ts.map +1 -0
- package/dist/routes/pdpp-auth.js +953 -0
- package/dist/routes/pdpp-auth.js.map +1 -0
- package/dist/routes/pdpp-blobs.d.ts +26 -0
- package/dist/routes/pdpp-blobs.d.ts.map +1 -0
- package/dist/routes/pdpp-blobs.js +179 -0
- package/dist/routes/pdpp-blobs.js.map +1 -0
- package/dist/routes/pdpp-connections.d.ts +28 -0
- package/dist/routes/pdpp-connections.d.ts.map +1 -0
- package/dist/routes/pdpp-connections.js +144 -0
- package/dist/routes/pdpp-connections.js.map +1 -0
- package/dist/routes/pdpp-declarations.d.ts +58 -0
- package/dist/routes/pdpp-declarations.d.ts.map +1 -0
- package/dist/routes/pdpp-declarations.js +94 -0
- package/dist/routes/pdpp-declarations.js.map +1 -0
- package/dist/routes/pdpp-instance-bindings.d.ts +26 -0
- package/dist/routes/pdpp-instance-bindings.d.ts.map +1 -0
- package/dist/routes/pdpp-instance-bindings.js +124 -0
- package/dist/routes/pdpp-instance-bindings.js.map +1 -0
- package/dist/routes/pdpp-records.d.ts +158 -0
- package/dist/routes/pdpp-records.d.ts.map +1 -0
- package/dist/routes/pdpp-records.js +1537 -0
- package/dist/routes/pdpp-records.js.map +1 -0
- package/dist/routes/pdpp-well-known.d.ts +13 -0
- package/dist/routes/pdpp-well-known.d.ts.map +1 -0
- package/dist/routes/pdpp-well-known.js +20 -0
- package/dist/routes/pdpp-well-known.js.map +1 -0
- package/dist/storage/declaration-witness.d.ts +56 -0
- package/dist/storage/declaration-witness.d.ts.map +1 -0
- package/dist/storage/declaration-witness.js +273 -0
- package/dist/storage/declaration-witness.js.map +1 -0
- package/dist/storage/hierarchy.d.ts +25 -1
- package/dist/storage/hierarchy.d.ts.map +1 -1
- package/dist/storage/hierarchy.js +210 -16
- package/dist/storage/hierarchy.js.map +1 -1
- package/dist/storage/import-operations.d.ts +11 -0
- package/dist/storage/import-operations.d.ts.map +1 -0
- package/dist/storage/import-operations.js +261 -0
- package/dist/storage/import-operations.js.map +1 -0
- package/dist/storage/index-manager.d.ts +24 -2
- package/dist/storage/index-manager.d.ts.map +1 -1
- package/dist/storage/index-manager.js +279 -30
- package/dist/storage/index-manager.js.map +1 -1
- package/dist/storage/index-schema.d.ts +1 -1
- package/dist/storage/index-schema.d.ts.map +1 -1
- package/dist/storage/index-schema.js +189 -8
- package/dist/storage/index-schema.js.map +1 -1
- package/dist/storage/legacy-scope-authority.d.ts +14 -0
- package/dist/storage/legacy-scope-authority.d.ts.map +1 -0
- package/dist/storage/legacy-scope-authority.js +298 -0
- package/dist/storage/legacy-scope-authority.js.map +1 -0
- package/dist/storage/node-data-storage.d.ts +6 -0
- package/dist/storage/node-data-storage.d.ts.map +1 -1
- package/dist/storage/node-data-storage.js +161 -2
- package/dist/storage/node-data-storage.js.map +1 -1
- package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts +17 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts.map +1 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.js +159 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.js.map +1 -0
- package/dist/storage/pdpp-records-sqlite-store.d.ts +112 -0
- package/dist/storage/pdpp-records-sqlite-store.d.ts.map +1 -0
- package/dist/storage/pdpp-records-sqlite-store.js +1440 -0
- package/dist/storage/pdpp-records-sqlite-store.js.map +1 -0
- package/dist/storage/pdpp-stream-completion.d.ts +5 -0
- package/dist/storage/pdpp-stream-completion.d.ts.map +1 -0
- package/dist/storage/pdpp-stream-completion.js +10 -0
- package/dist/storage/pdpp-stream-completion.js.map +1 -0
- package/dist/storage/scope-deletion-history.d.ts +8 -0
- package/dist/storage/scope-deletion-history.d.ts.map +1 -0
- package/dist/storage/scope-deletion-history.js +40 -0
- package/dist/storage/scope-deletion-history.js.map +1 -0
- package/dist/ui/ps-lite-debug.js +3981 -1109
- package/package.json +12 -4
|
@@ -0,0 +1,1537 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { isRfc3339DateTime, PdppBindingError, } from "../storage/pdpp-records-sqlite-store.js";
|
|
4
|
+
import { PdppError } from "@opendatalabs/personal-server-ts-core/errors/pdpp";
|
|
5
|
+
import { PDPP_VERSION } from "@opendatalabs/personal-server-ts-core/pdpp-version";
|
|
6
|
+
import { CursorExpiredError, decodeCursor, encodeCursor, InvalidCursorError, InvalidCursorSyntaxError, recordKeyWithinGrantResources, mapInactiveToError, recordWithinGrantTimeConstraint, resolveReadScope, summarizeIngest, } from "@opendatalabs/personal-server-ts-core/storage/pdpp-records";
|
|
7
|
+
import { createRecordDataValidator } from "../pdpp/record-schema.js";
|
|
8
|
+
import { StreamCompletionError, } from "../storage/pdpp-stream-completion.js";
|
|
9
|
+
const DEFAULT_LIMIT = 25;
|
|
10
|
+
const MAX_LIMIT = 100;
|
|
11
|
+
export const MAX_BLOB_UPLOAD_BYTES = 512 * 1024 * 1024;
|
|
12
|
+
function rebindListCursor(template, order, last) {
|
|
13
|
+
const bound = decodeCursor(template);
|
|
14
|
+
if (bound.kind !== "list")
|
|
15
|
+
throw new InvalidCursorError();
|
|
16
|
+
return encodeCursor({
|
|
17
|
+
...bound,
|
|
18
|
+
order,
|
|
19
|
+
sortValue: last.emittedAt,
|
|
20
|
+
recordKey: last.recordKey,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** 512 MiB — max body size for PDPP record ingest and replace routes. */
|
|
24
|
+
export const MAX_INGEST_BODY_BYTES = 512 * 1024 * 1024;
|
|
25
|
+
/** 64 KiB of UTF-8 — max body size for the completion metadata route. */
|
|
26
|
+
export const MAX_COMPLETION_BODY_BYTES = 64 * 1024;
|
|
27
|
+
class CompletionBodyTooLarge extends Error {
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read a completion body, counting the bytes actually received. Unlike
|
|
31
|
+
* `readBoundedBody`, the size decision never consults Content-Length, so an
|
|
32
|
+
* absent or lying header cannot move the limit, and the over-limit answer is
|
|
33
|
+
* a 413 rather than a 400.
|
|
34
|
+
*/
|
|
35
|
+
async function readCompletionBody(request) {
|
|
36
|
+
const reader = request.body?.getReader();
|
|
37
|
+
if (!reader) {
|
|
38
|
+
throw new PdppError("invalid_request", "Completion body is empty");
|
|
39
|
+
}
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
while (true) {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done)
|
|
45
|
+
break;
|
|
46
|
+
size += value.byteLength;
|
|
47
|
+
if (size > MAX_COMPLETION_BODY_BYTES) {
|
|
48
|
+
void reader.cancel().catch(() => undefined);
|
|
49
|
+
throw new CompletionBodyTooLarge();
|
|
50
|
+
}
|
|
51
|
+
chunks.push(value);
|
|
52
|
+
}
|
|
53
|
+
if (size === 0) {
|
|
54
|
+
throw new PdppError("invalid_request", "Completion body is empty");
|
|
55
|
+
}
|
|
56
|
+
const bytes = new Uint8Array(size);
|
|
57
|
+
let offset = 0;
|
|
58
|
+
for (const chunk of chunks) {
|
|
59
|
+
bytes.set(chunk, offset);
|
|
60
|
+
offset += chunk.byteLength;
|
|
61
|
+
}
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
const COMPLETION_BODY_KEYS = [
|
|
65
|
+
"source_id",
|
|
66
|
+
"instance",
|
|
67
|
+
"source_collected_at",
|
|
68
|
+
"collection_time_basis",
|
|
69
|
+
"streams",
|
|
70
|
+
];
|
|
71
|
+
const COMPLETION_STREAM_KEYS = [
|
|
72
|
+
"stream",
|
|
73
|
+
"coverage",
|
|
74
|
+
"live_count",
|
|
75
|
+
"snapshot_sha256",
|
|
76
|
+
];
|
|
77
|
+
function hasExactKeys(value, keys) {
|
|
78
|
+
const actual = Object.keys(value);
|
|
79
|
+
return (actual.length === keys.length && keys.every((key) => actual.includes(key)));
|
|
80
|
+
}
|
|
81
|
+
/** Store refusals that are the caller's conflict with current state. */
|
|
82
|
+
const COMPLETION_CONFLICTS = [
|
|
83
|
+
"snapshot_conflict",
|
|
84
|
+
"time_conflict",
|
|
85
|
+
"invalid_snapshot_data",
|
|
86
|
+
"invalid_prior_receipt",
|
|
87
|
+
];
|
|
88
|
+
const BLOB_MEDIA_TYPE = /^(application|audio|example|font|haptics|image|message|model|multipart|text|video)\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i;
|
|
89
|
+
function blobMediaType(contentType) {
|
|
90
|
+
const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
|
|
91
|
+
if (!mediaType || !BLOB_MEDIA_TYPE.test(mediaType)) {
|
|
92
|
+
throw new PdppError("invalid_request", "Content-Type must be a valid media type");
|
|
93
|
+
}
|
|
94
|
+
return mediaType;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Read a request body of at most `maxBytes`, refusing early on a declared
|
|
98
|
+
* Content-Length over the limit and while streaming otherwise, so an
|
|
99
|
+
* oversize body is never buffered whole.
|
|
100
|
+
*/
|
|
101
|
+
async function readBoundedBody(request, maxBytes, label) {
|
|
102
|
+
const tooLarge = () => new PdppError("invalid_request", `${label} body exceeds the ${maxBytes / (1024 * 1024)} MiB limit`);
|
|
103
|
+
const contentLength = request.headers.get("content-length");
|
|
104
|
+
if (contentLength !== null &&
|
|
105
|
+
(!/^\d+$/.test(contentLength) || Number(contentLength) > maxBytes)) {
|
|
106
|
+
throw tooLarge();
|
|
107
|
+
}
|
|
108
|
+
const reader = request.body?.getReader();
|
|
109
|
+
if (!reader)
|
|
110
|
+
throw new PdppError("invalid_request", `${label} body is empty`);
|
|
111
|
+
const chunks = [];
|
|
112
|
+
let size = 0;
|
|
113
|
+
while (true) {
|
|
114
|
+
const { done, value } = await reader.read();
|
|
115
|
+
if (done)
|
|
116
|
+
break;
|
|
117
|
+
size += value.byteLength;
|
|
118
|
+
if (size > maxBytes) {
|
|
119
|
+
// Cancellation is best effort; a stalled source must not hold the 400 response.
|
|
120
|
+
void reader.cancel().catch(() => undefined);
|
|
121
|
+
throw tooLarge();
|
|
122
|
+
}
|
|
123
|
+
chunks.push(value);
|
|
124
|
+
}
|
|
125
|
+
if (size === 0)
|
|
126
|
+
throw new PdppError("invalid_request", `${label} body is empty`);
|
|
127
|
+
if (contentLength !== null && size !== Number(contentLength)) {
|
|
128
|
+
throw new PdppError("invalid_request", `Content-Length does not match ${label.toLowerCase()} body`);
|
|
129
|
+
}
|
|
130
|
+
const bytes = new Uint8Array(size);
|
|
131
|
+
let offset = 0;
|
|
132
|
+
for (const chunk of chunks) {
|
|
133
|
+
bytes.set(chunk, offset);
|
|
134
|
+
offset += chunk.byteLength;
|
|
135
|
+
}
|
|
136
|
+
return bytes;
|
|
137
|
+
}
|
|
138
|
+
function parseIngestBody(bytes) {
|
|
139
|
+
let body;
|
|
140
|
+
try {
|
|
141
|
+
body = JSON.parse(new TextDecoder().decode(bytes));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new PdppError("invalid_request", "Ingest body is not valid JSON");
|
|
145
|
+
}
|
|
146
|
+
if (body === null || typeof body !== "object") {
|
|
147
|
+
throw new PdppError("invalid_request", "Ingest body must be a RECORD envelope or an array of envelopes");
|
|
148
|
+
}
|
|
149
|
+
return body;
|
|
150
|
+
}
|
|
151
|
+
function requestId() {
|
|
152
|
+
return `req_${randomUUID()}`;
|
|
153
|
+
}
|
|
154
|
+
/** Maps a thrown store-layer error to the PdppError the spec's Errors table requires. */
|
|
155
|
+
function toPdppError(err) {
|
|
156
|
+
if (err instanceof PdppError)
|
|
157
|
+
return err;
|
|
158
|
+
if (err instanceof CursorExpiredError) {
|
|
159
|
+
return new PdppError("cursor_expired", "changes_since cursor has expired");
|
|
160
|
+
}
|
|
161
|
+
// Two distinct classes reach here and both mean "this cursor is not usable":
|
|
162
|
+
// the stores throw `InvalidCursorError` (decoded fine, but wrong order or
|
|
163
|
+
// wrong kind), while `decodeCursor` throws `InvalidCursorSyntaxError` for a
|
|
164
|
+
// token that is not even base64url JSON. Only the first was mapped, so a
|
|
165
|
+
// malformed cursor escaped as an unhandled throw -- a 500 with no PDPP error
|
|
166
|
+
// body, telling a client nothing and looking like a server fault when it was
|
|
167
|
+
// a bad request.
|
|
168
|
+
if (err instanceof InvalidCursorError ||
|
|
169
|
+
err instanceof InvalidCursorSyntaxError) {
|
|
170
|
+
return new PdppError("invalid_cursor", "Cursor token is malformed, unrecognized, or was reused with a different order");
|
|
171
|
+
}
|
|
172
|
+
// Anything else is our fault, not the client's. Rethrowing sent it past
|
|
173
|
+
// every route-level catch to the framework's generic handler, which
|
|
174
|
+
// answered a body that is not a PDPP error object and knows neither the
|
|
175
|
+
// route nor this request's `Request-Id` — so the line an operator finds in
|
|
176
|
+
// the log could not be tied to the response a client reported. Mapping it
|
|
177
|
+
// here keeps the wire contract intact (§8 Errors: `api_error`, 500) and
|
|
178
|
+
// gives the caller a correlatable id; `logApiError` writes the detail.
|
|
179
|
+
return new PdppError("api_error", "Internal server error");
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The one place an `api_error` is recorded. Structured, and deliberately
|
|
183
|
+
* narrow: route, request id, error code, and the error's own name/message/
|
|
184
|
+
* stack. No Authorization header, no token, no grant, no record body — a
|
|
185
|
+
* resource server's log must not become a second copy of the data it
|
|
186
|
+
* protects, nor a place credentials come to rest.
|
|
187
|
+
*/
|
|
188
|
+
function logApiError(deps, route, reqId, err) {
|
|
189
|
+
if (!deps.logger)
|
|
190
|
+
return;
|
|
191
|
+
deps.logger.error({
|
|
192
|
+
route,
|
|
193
|
+
requestId: reqId,
|
|
194
|
+
errorCode: "api_error",
|
|
195
|
+
err: err instanceof Error
|
|
196
|
+
? { name: err.name, message: err.message, stack: err.stack }
|
|
197
|
+
: { message: String(err) },
|
|
198
|
+
}, "PDPP resource server error");
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Map a thrown error to its PDPP error, logging it first when it is a server
|
|
202
|
+
* fault rather than a client one — a 403 on a revoked grant is normal traffic
|
|
203
|
+
* and must not fill the log. Returns the mapped error so callers can both
|
|
204
|
+
* send it and (on the record routes) record the access-feed outcome from it.
|
|
205
|
+
*/
|
|
206
|
+
function mapAndLog(deps, route, reqId, err) {
|
|
207
|
+
const mapped = toPdppError(err);
|
|
208
|
+
if (mapped.status === 500 && !(err instanceof PdppError)) {
|
|
209
|
+
logApiError(deps, route, reqId, err);
|
|
210
|
+
}
|
|
211
|
+
return mapped;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* `PdppTokenContext.subjectId` is optional in the real AS contract (it may
|
|
215
|
+
* be absent on an inactive token). By the time route code reaches an
|
|
216
|
+
* owner-token branch the token is already known active, so a missing
|
|
217
|
+
* subjectId here is an AS-side bug, not a client error — fail closed rather
|
|
218
|
+
* than pass `undefined` through to instance-scoping.
|
|
219
|
+
*/
|
|
220
|
+
function requireSubjectId(context) {
|
|
221
|
+
if (!context.subjectId) {
|
|
222
|
+
throw new PdppError("authentication_error", "Token context is missing subject_id");
|
|
223
|
+
}
|
|
224
|
+
return context.subjectId;
|
|
225
|
+
}
|
|
226
|
+
function sendError(c, err, reqId) {
|
|
227
|
+
// Every 401 carries the challenge, whichever branch produced it.
|
|
228
|
+
//
|
|
229
|
+
// Previously only the missing-token branch built one, so a client holding a
|
|
230
|
+
// STALE token -- the exact case §8's challenge exists to bootstrap -- got a
|
|
231
|
+
// bare 401 with no `WWW-Authenticate` and no pointer to the metadata it
|
|
232
|
+
// needed in order to re-authorize. Attaching it here rather than at each
|
|
233
|
+
// call site means a future 401 path cannot forget it.
|
|
234
|
+
const headers = {
|
|
235
|
+
"Request-Id": reqId,
|
|
236
|
+
"PDPP-Version": PDPP_VERSION,
|
|
237
|
+
};
|
|
238
|
+
if (err.status === 401) {
|
|
239
|
+
headers["WWW-Authenticate"] =
|
|
240
|
+
`Bearer error="invalid_token", resource_metadata="${resourceMetadataUrlFor(c)}"`;
|
|
241
|
+
}
|
|
242
|
+
return c.json(err.toJSON(reqId), err.status, headers);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The RFC 9728 protected-resource metadata URL for THIS request's origin.
|
|
246
|
+
*
|
|
247
|
+
* RFC 9728 §5.1 requires `resource_metadata` to be a URI the client can
|
|
248
|
+
* dereference. A relative path only resolves if the client already knows the
|
|
249
|
+
* origin -- which an unauthenticated client bootstrapping discovery from a
|
|
250
|
+
* failed read cannot be assumed to have. Deriving it from the request also
|
|
251
|
+
* keeps it correct behind a tunnel or proxy, where a hardcoded origin would
|
|
252
|
+
* point the client somewhere it cannot reach.
|
|
253
|
+
*/
|
|
254
|
+
export function resourceMetadataUrlFor(c) {
|
|
255
|
+
return new URL("/.well-known/oauth-protected-resource", new URL(c.req.url).origin).toString();
|
|
256
|
+
}
|
|
257
|
+
function unauthorized(c, reqId, resourceMetadataUrl) {
|
|
258
|
+
const err = new PdppError("authentication_error", "Missing or invalid access token");
|
|
259
|
+
return c.json(err.toJSON(reqId), 401, {
|
|
260
|
+
"Request-Id": reqId,
|
|
261
|
+
"PDPP-Version": PDPP_VERSION,
|
|
262
|
+
"WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
function parseLimit(raw) {
|
|
266
|
+
if (!raw)
|
|
267
|
+
return { limit: DEFAULT_LIMIT, clamped: false };
|
|
268
|
+
const parsed = Number.parseInt(raw, 10);
|
|
269
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
270
|
+
throw new PdppError("invalid_request", "limit must be a positive integer", {
|
|
271
|
+
param: "limit",
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (parsed > MAX_LIMIT)
|
|
275
|
+
return { limit: MAX_LIMIT, clamped: true };
|
|
276
|
+
return { limit: parsed, clamped: false };
|
|
277
|
+
}
|
|
278
|
+
function parseOrder(raw) {
|
|
279
|
+
if (raw === undefined)
|
|
280
|
+
return "desc";
|
|
281
|
+
if (raw === "asc" || raw === "desc")
|
|
282
|
+
return raw;
|
|
283
|
+
throw new PdppError("invalid_request", "order must be 'asc' or 'desc'", {
|
|
284
|
+
param: "order",
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/** Query parameters implemented by each endpoint. Unsupported shapes must fail. */
|
|
288
|
+
const KNOWN_QUERY_PARAMS = {
|
|
289
|
+
listStreams: [],
|
|
290
|
+
streamMetadata: [],
|
|
291
|
+
listRecords: ["limit", "order", "cursor", "fields", "changes_since"],
|
|
292
|
+
getRecord: ["fields"],
|
|
293
|
+
deleteRecord: [],
|
|
294
|
+
};
|
|
295
|
+
/** Validate exact parameter names before consulting serving metadata. */
|
|
296
|
+
function rejectUnknownParams(c, endpoint, tokenKind) {
|
|
297
|
+
const allowed = KNOWN_QUERY_PARAMS[endpoint];
|
|
298
|
+
const recordRead = endpoint === "listRecords" || endpoint === "getRecord";
|
|
299
|
+
for (const key of new URL(c.req.url).searchParams.keys()) {
|
|
300
|
+
if (allowed.includes(key))
|
|
301
|
+
continue;
|
|
302
|
+
const base = key.replace(/\[.*$/, "");
|
|
303
|
+
// Current serving metadata declares no expandable relations. Client
|
|
304
|
+
// requests must still receive invalid_request, before metadata lookup.
|
|
305
|
+
if (recordRead &&
|
|
306
|
+
tokenKind === "owner" &&
|
|
307
|
+
(base === "expand" || base === "expand_limit")) {
|
|
308
|
+
throw new PdppError("invalid_expand", "No expandable relation is declared", {
|
|
309
|
+
param: key,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
throw new PdppError("invalid_request", `Unsupported query parameter '${key}'`, {
|
|
313
|
+
param: key,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Narrow a client's requested `fields` within what its grant already allows.
|
|
319
|
+
*
|
|
320
|
+
* Three rules, in order:
|
|
321
|
+
* - a field outside the grant is a 400, not a silent drop. A client asking
|
|
322
|
+
* for something it was never granted has made an error it needs to see;
|
|
323
|
+
* quietly returning less would let it believe it received that field.
|
|
324
|
+
* - the result is the intersection, so a request can only ever narrow.
|
|
325
|
+
* - under a v0.1 grant ONLY, the declaration's required fields are re-added,
|
|
326
|
+
* because v0.1 §8 keeps schema-required fields in every projection
|
|
327
|
+
* regardless of the request. A v0.2 grant gets the bare intersection:
|
|
328
|
+
* `v0.2-4-2` builds disclosed `data` from only the members permitted by
|
|
329
|
+
* the grant AND the request-time selection, and `v0.2-4-1` forbids adding
|
|
330
|
+
* one back because the schema requires it. Re-widening a sparse v0.2
|
|
331
|
+
* request would disclose more than both the owner and the client asked
|
|
332
|
+
* for, which is the one direction this function must never move.
|
|
333
|
+
*
|
|
334
|
+
* Returns the full granted set when no `fields` was requested, preserving the
|
|
335
|
+
* existing default.
|
|
336
|
+
*/
|
|
337
|
+
function narrowClientFields(requested, granted, declaration, schemaRequiredFloor) {
|
|
338
|
+
if (!requested || requested.length === 0)
|
|
339
|
+
return granted;
|
|
340
|
+
if (!granted)
|
|
341
|
+
return requested;
|
|
342
|
+
const grantedSet = new Set(granted);
|
|
343
|
+
for (const field of requested) {
|
|
344
|
+
if (!grantedSet.has(field)) {
|
|
345
|
+
throw new PdppError("invalid_request", `Field '${field}' is not in this grant's authorized fields for the stream`, { param: "fields" });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const narrowed = new Set(requested.filter((f) => grantedSet.has(f)));
|
|
349
|
+
if (schemaRequiredFloor) {
|
|
350
|
+
// v0.1 only: the consent floor survives a sparse request.
|
|
351
|
+
for (const required of declaration?.requiredFields ?? []) {
|
|
352
|
+
if (grantedSet.has(required))
|
|
353
|
+
narrowed.add(required);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return Array.from(narrowed);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Project a declared JSON Schema down to exactly the granted fields.
|
|
360
|
+
*
|
|
361
|
+
* A client token must not learn that a field exists outside its grant, so
|
|
362
|
+
* `properties` is filtered rather than passed through, and `required` is
|
|
363
|
+
* intersected. Without a declared schema this degrades to bare property names
|
|
364
|
+
* — the previous behavior — rather than inventing structure.
|
|
365
|
+
*/
|
|
366
|
+
function projectSchemaToFields(schema, fields) {
|
|
367
|
+
const allowed = new Set(fields ?? []);
|
|
368
|
+
if (!schema) {
|
|
369
|
+
return {
|
|
370
|
+
properties: Object.fromEntries([...allowed].map((f) => [f, {}])),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const properties = (schema.properties ?? {});
|
|
374
|
+
const required = Array.isArray(schema.required)
|
|
375
|
+
? schema.required.filter((f) => allowed.has(f))
|
|
376
|
+
: [];
|
|
377
|
+
return {
|
|
378
|
+
...schema,
|
|
379
|
+
properties: Object.fromEntries(Object.entries(properties).filter(([name]) => allowed.has(name))),
|
|
380
|
+
...(required.length > 0 ? { required } : { required: [] }),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
export function pdppRecordsRoutes(deps) {
|
|
384
|
+
const app = new Hono();
|
|
385
|
+
const validateData = createRecordDataValidator(deps.declarations);
|
|
386
|
+
/**
|
|
387
|
+
* Append one PDPP read to the owner access feed.
|
|
388
|
+
*
|
|
389
|
+
* Only CLIENT reads are logged. An owner reading their own store is not a
|
|
390
|
+
* third-party access event, and the legacy feed does not record those
|
|
391
|
+
* either — logging them would change what the feed means.
|
|
392
|
+
*
|
|
393
|
+
* Never throws: a feed write that fails must not turn a served read into an
|
|
394
|
+
* error, and must not turn a denial into a 500. Failures are surfaced by
|
|
395
|
+
* the writer's own logging, not by breaking the request.
|
|
396
|
+
*/
|
|
397
|
+
async function logClientRead(c, context, stream, outcome, reqId) {
|
|
398
|
+
if (!deps.accessLog)
|
|
399
|
+
return;
|
|
400
|
+
if (context?.tokenKind !== "client")
|
|
401
|
+
return;
|
|
402
|
+
const grantId = context.grant?.grant_id;
|
|
403
|
+
const clientId = context.clientId;
|
|
404
|
+
if (!grantId || !clientId)
|
|
405
|
+
return;
|
|
406
|
+
try {
|
|
407
|
+
await deps.accessLog.record({
|
|
408
|
+
clientId,
|
|
409
|
+
grantId,
|
|
410
|
+
ipAddress: c.req.header("x-forwarded-for") ??
|
|
411
|
+
c.req.header("x-real-ip") ??
|
|
412
|
+
"unknown",
|
|
413
|
+
operation: "read",
|
|
414
|
+
outcome,
|
|
415
|
+
requestId: reqId,
|
|
416
|
+
stream,
|
|
417
|
+
userAgent: c.req.header("user-agent") ?? "unknown",
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// Intentionally swallowed; see the doc comment.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
app.use("*", async (c, next) => {
|
|
425
|
+
const reqId = requestId();
|
|
426
|
+
c.set("reqId", reqId);
|
|
427
|
+
await next();
|
|
428
|
+
});
|
|
429
|
+
async function authenticate(c, reqId) {
|
|
430
|
+
const header = c.req.header("Authorization");
|
|
431
|
+
const token = header?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
432
|
+
if (!token) {
|
|
433
|
+
return { error: unauthorized(c, reqId, resourceMetadataUrlFor(c)) };
|
|
434
|
+
}
|
|
435
|
+
const context = await deps.auth.resolveToken(token);
|
|
436
|
+
// Chain enforcement, when the deployment configures it. Placed here so
|
|
437
|
+
// EVERY endpoint is covered by one gate: a check added per-route is a
|
|
438
|
+
// check that a later route forgets, and an unenforced read path is
|
|
439
|
+
// exactly the defect this closes.
|
|
440
|
+
//
|
|
441
|
+
// Only client tokens are gated. An owner token is the owner reading their
|
|
442
|
+
// own data, which no chain permission mediates -- requiring a grantee
|
|
443
|
+
// permission for it would lock owners out of their own server.
|
|
444
|
+
if (deps.chainEnforcement && context?.active) {
|
|
445
|
+
if (context.tokenKind === "client") {
|
|
446
|
+
const grantId = context.grant?.grant_id;
|
|
447
|
+
if (!grantId) {
|
|
448
|
+
// An active client token with no grant cannot be bound to anything,
|
|
449
|
+
// so there is nothing to verify and nothing to allow.
|
|
450
|
+
return {
|
|
451
|
+
error: sendError(c, new PdppError("grant_revoked", "No chain binding for this grant"), reqId),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const decision = await deps.chainEnforcement.authorize({
|
|
455
|
+
clientId: context.clientId ?? "",
|
|
456
|
+
pdppGrantId: grantId,
|
|
457
|
+
});
|
|
458
|
+
if (!decision.ok) {
|
|
459
|
+
await logClientRead(c, context, "unknown", "denied", reqId);
|
|
460
|
+
// A retryable failure is NOT reported as a denial. The grant may be
|
|
461
|
+
// perfectly valid and the chain merely unreachable, so answering
|
|
462
|
+
// `grant_revoked` would send a good client off to re-consent over an
|
|
463
|
+
// outage. §8 defines no 503 code and inventing one would be a spec
|
|
464
|
+
// extension, so this maps to `api_error` (500) -- which is honest:
|
|
465
|
+
// the failure is on our side, not in the client's request. The
|
|
466
|
+
// `Retry-After` hint costs nothing and tells a well-behaved client
|
|
467
|
+
// to come back rather than give up.
|
|
468
|
+
if (decision.retryable) {
|
|
469
|
+
c.header("Retry-After", "5");
|
|
470
|
+
return {
|
|
471
|
+
error: sendError(c, new PdppError("api_error", "Chain authorization could not be verified"), reqId),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
error: sendError(c, new PdppError("grant_revoked", decision.reason), reqId),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { context };
|
|
481
|
+
}
|
|
482
|
+
// PDPP-Version negotiation: only PDPP_VERSION is supported in v0.1.
|
|
483
|
+
app.use("*", async (c, next) => {
|
|
484
|
+
const requested = c.req.header("PDPP-Version");
|
|
485
|
+
if (requested && requested !== PDPP_VERSION) {
|
|
486
|
+
const reqId = requestId();
|
|
487
|
+
const err = new PdppError("unsupported_version", `Unsupported PDPP-Version '${requested}'`);
|
|
488
|
+
return sendError(c, err, reqId);
|
|
489
|
+
}
|
|
490
|
+
await next();
|
|
491
|
+
});
|
|
492
|
+
/**
|
|
493
|
+
* The declaration a read of `stream` is measured against.
|
|
494
|
+
*
|
|
495
|
+
* A client reads under its grant, which names one source. An owner reads
|
|
496
|
+
* only the token's currently owned instance whose source declares the
|
|
497
|
+
* stream. The declaration only answers whether that stream exists;
|
|
498
|
+
* records carry their own shape.
|
|
499
|
+
*/
|
|
500
|
+
function readDeclaration(context, stream) {
|
|
501
|
+
if (context.tokenKind !== "owner") {
|
|
502
|
+
return {
|
|
503
|
+
declaration: deps.declarations.get(stream, context.grant?.source.id),
|
|
504
|
+
ownerInstanceIds: [],
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const owned = ownerInstances(context);
|
|
508
|
+
const ownerInstanceIds = owned.filter((instance) => deps.declarations.forInstance(instance, stream));
|
|
509
|
+
const declaration = ownerInstanceIds.length > 0
|
|
510
|
+
? deps.declarations.forInstance(ownerInstanceIds[0], stream)
|
|
511
|
+
: deps.declarations.get(stream);
|
|
512
|
+
return { declaration, ownerInstanceIds };
|
|
513
|
+
}
|
|
514
|
+
function ownerInstances(context) {
|
|
515
|
+
if (context.tokenKind !== "owner")
|
|
516
|
+
return [];
|
|
517
|
+
if (context.sourceId) {
|
|
518
|
+
const current = deps.instancesForSource?.(requireSubjectId(context), context.sourceId) ?? [];
|
|
519
|
+
if (!context.instanceIds)
|
|
520
|
+
return current;
|
|
521
|
+
return context.instanceIds.filter((instance) => current.includes(instance));
|
|
522
|
+
}
|
|
523
|
+
const current = deps.instancesForSubject?.(requireSubjectId(context));
|
|
524
|
+
if (!current)
|
|
525
|
+
return [];
|
|
526
|
+
if (!context.instanceIds)
|
|
527
|
+
return [];
|
|
528
|
+
return context.instanceIds.filter((instance) => current.includes(instance));
|
|
529
|
+
}
|
|
530
|
+
function requireRegisteredConnections(instanceIds) {
|
|
531
|
+
if (!deps.bindingStore)
|
|
532
|
+
return;
|
|
533
|
+
if (instanceIds.some((instance) => {
|
|
534
|
+
const binding = deps.bindingStore.getInstanceBinding(instance);
|
|
535
|
+
return binding.sourceId === null || binding.deletedAt !== null;
|
|
536
|
+
})) {
|
|
537
|
+
throw new PdppError("instance_unavailable", "The connection for this grant is unavailable");
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
app.get("/streams", async (c) => {
|
|
541
|
+
const reqId = requestId();
|
|
542
|
+
const { context, error } = await authenticate(c, reqId);
|
|
543
|
+
if (error)
|
|
544
|
+
return error;
|
|
545
|
+
try {
|
|
546
|
+
rejectUnknownParams(c, "listStreams");
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
return sendError(c, mapAndLog(deps, "GET /v1/streams", reqId, err), reqId);
|
|
550
|
+
}
|
|
551
|
+
// An inactive token must report the SAME reason here as on a record read.
|
|
552
|
+
// This branch used to answer a flat `authentication_error` (401) while
|
|
553
|
+
// `/streams/:stream/records` answered `grant_revoked` (403) for the very
|
|
554
|
+
// same token, so a client could not tell a revoked grant from a bad token
|
|
555
|
+
// depending only on which endpoint it happened to call first. Found by
|
|
556
|
+
// driving Context Gateway's real PdppContextClient against this server.
|
|
557
|
+
if (!context?.active) {
|
|
558
|
+
return sendError(c, mapInactiveToError(context), reqId);
|
|
559
|
+
}
|
|
560
|
+
const instanceIds = context.tokenKind === "owner"
|
|
561
|
+
? ownerInstances(context)
|
|
562
|
+
: (context.grant?.streams.flatMap((s) => s.instance_ids) ?? []);
|
|
563
|
+
if (context.tokenKind === "owner" && instanceIds.length === 0) {
|
|
564
|
+
return sendError(c, mapAndLog(deps, "GET /v1/streams", reqId, new PdppError("grant_stream_not_allowed", "Owner token is not scoped to a currently owned instance")), reqId);
|
|
565
|
+
}
|
|
566
|
+
const streams = deps.store.listStreams(instanceIds);
|
|
567
|
+
// A client token gets a closed projection: only the streams its grant
|
|
568
|
+
// names, and counts/recency computed over only the records the grant
|
|
569
|
+
// actually exposes. The store's raw listing counts every record in the
|
|
570
|
+
// instance, so returning it unchanged would leak the existence and
|
|
571
|
+
// recency of records outside the grant's time_constraint / resources.
|
|
572
|
+
const visibleStreams = context.tokenKind === "owner"
|
|
573
|
+
? streams.map((s) => ({
|
|
574
|
+
name: s.stream,
|
|
575
|
+
recordCount: s.recordCount,
|
|
576
|
+
lastUpdated: s.lastUpdated,
|
|
577
|
+
budgetExhausted: false,
|
|
578
|
+
}))
|
|
579
|
+
: streams
|
|
580
|
+
.filter((s) => context.grant?.streams.some((g) => g.name === s.stream))
|
|
581
|
+
.map((s) => {
|
|
582
|
+
const streamGrant = context.grant?.streams.find((g) => g.name === s.stream);
|
|
583
|
+
return {
|
|
584
|
+
name: s.stream,
|
|
585
|
+
...summarizeGrantVisible(deps.store, s.stream, streamGrant?.instance_ids ?? [], streamGrant),
|
|
586
|
+
};
|
|
587
|
+
});
|
|
588
|
+
// Report truncation explicitly rather than letting a capped count read as
|
|
589
|
+
// exact. `meta.warnings` is the same shape the list endpoint uses for
|
|
590
|
+
// `limit_clamped`.
|
|
591
|
+
const cappedStreams = visibleStreams
|
|
592
|
+
.filter((s) => s.budgetExhausted)
|
|
593
|
+
.map((s) => s.name);
|
|
594
|
+
return c.json({
|
|
595
|
+
object: "list",
|
|
596
|
+
data: visibleStreams.map((s) => ({
|
|
597
|
+
object: "stream",
|
|
598
|
+
name: s.name,
|
|
599
|
+
record_count: s.recordCount,
|
|
600
|
+
last_updated: s.lastUpdated,
|
|
601
|
+
})),
|
|
602
|
+
...(cappedStreams.length > 0 && {
|
|
603
|
+
meta: {
|
|
604
|
+
warnings: [
|
|
605
|
+
{
|
|
606
|
+
code: "record_count_not_counted",
|
|
607
|
+
// Machine-readable: clients must not parse the prose to learn
|
|
608
|
+
// which streams were truncated.
|
|
609
|
+
streams: cappedStreams,
|
|
610
|
+
message: `record_count is null for ${cappedStreams.join(", ")}: more than ${STREAM_COUNT_CAP} grant-visible records, or the scan budget was reached`,
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
},
|
|
614
|
+
}),
|
|
615
|
+
}, 200, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
616
|
+
});
|
|
617
|
+
app.get("/streams/:stream", async (c) => {
|
|
618
|
+
const reqId = requestId();
|
|
619
|
+
const { context, error } = await authenticate(c, reqId);
|
|
620
|
+
if (error)
|
|
621
|
+
return error;
|
|
622
|
+
try {
|
|
623
|
+
rejectUnknownParams(c, "streamMetadata");
|
|
624
|
+
const stream = c.req.param("stream");
|
|
625
|
+
const { declaration, ownerInstanceIds } = readDeclaration(context, stream);
|
|
626
|
+
const scope = resolveReadScope(context, stream, declaration);
|
|
627
|
+
if (context.tokenKind === "owner" && ownerInstanceIds.length === 0) {
|
|
628
|
+
throw new PdppError("grant_stream_not_allowed", "Owner token is not scoped to a currently owned instance");
|
|
629
|
+
}
|
|
630
|
+
if (context.tokenKind === "owner") {
|
|
631
|
+
return c.json({
|
|
632
|
+
object: "stream_metadata",
|
|
633
|
+
name: declaration.name,
|
|
634
|
+
primary_key: declaration.primaryKey,
|
|
635
|
+
cursor_field: declaration.cursorField,
|
|
636
|
+
consent_time_field: declaration.consentTimeField ?? null,
|
|
637
|
+
query: {},
|
|
638
|
+
views: [],
|
|
639
|
+
relationships: [],
|
|
640
|
+
// §8 owner metadata carries the full declared schema and
|
|
641
|
+
// selection capability. Omitting them made this a public response
|
|
642
|
+
// contract mismatch that access-behavior fixtures could not see.
|
|
643
|
+
schema: declaration.schema ?? null,
|
|
644
|
+
selection: declaration.selection ?? null,
|
|
645
|
+
}, 200, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
646
|
+
}
|
|
647
|
+
return c.json({
|
|
648
|
+
object: "stream_metadata",
|
|
649
|
+
name: declaration.name,
|
|
650
|
+
primary_key: declaration.primaryKey,
|
|
651
|
+
cursor_field: declaration.cursorField,
|
|
652
|
+
consent_time_field: declaration.consentTimeField ?? null,
|
|
653
|
+
query: {},
|
|
654
|
+
views: [],
|
|
655
|
+
relationships: [],
|
|
656
|
+
// Grant-closed: the declared schema projected to exactly the
|
|
657
|
+
// granted fields, so a client token learns record shape without
|
|
658
|
+
// learning the existence of fields it was not granted. Falls back
|
|
659
|
+
// to bare field names when the retained declaration carries no
|
|
660
|
+
// schema, rather than fabricating one.
|
|
661
|
+
schema: projectSchemaToFields(declaration.schema, scope.fields),
|
|
662
|
+
// The frozen selection capability the grant was resolved against.
|
|
663
|
+
selection: declaration.selection ?? null,
|
|
664
|
+
}, 200, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
return sendError(c, mapAndLog(deps, "GET /v1/streams/:stream", reqId, err), reqId);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
app.get("/streams/:stream/records", async (c) => {
|
|
671
|
+
const reqId = requestId();
|
|
672
|
+
const { context, error } = await authenticate(c, reqId);
|
|
673
|
+
if (error)
|
|
674
|
+
return error;
|
|
675
|
+
try {
|
|
676
|
+
rejectUnknownParams(c, "listRecords", context.tokenKind);
|
|
677
|
+
const stream = c.req.param("stream");
|
|
678
|
+
const { declaration, ownerInstanceIds } = readDeclaration(context, stream);
|
|
679
|
+
const scope = resolveReadScope(context, stream, declaration);
|
|
680
|
+
const effectiveInstanceIds = context.tokenKind === "owner" ? ownerInstanceIds : scope.instanceIds;
|
|
681
|
+
if (context.tokenKind === "client") {
|
|
682
|
+
requireRegisteredConnections(effectiveInstanceIds);
|
|
683
|
+
}
|
|
684
|
+
if (context.tokenKind === "owner" && effectiveInstanceIds.length === 0) {
|
|
685
|
+
throw new PdppError("grant_stream_not_allowed", "Owner token is not scoped to a currently owned instance");
|
|
686
|
+
}
|
|
687
|
+
const { limit, clamped } = parseLimit(c.req.query("limit"));
|
|
688
|
+
const order = parseOrder(c.req.query("order"));
|
|
689
|
+
const requestedFields = c.req
|
|
690
|
+
.query("fields")
|
|
691
|
+
?.split(",")
|
|
692
|
+
.map((f) => f.trim());
|
|
693
|
+
// §8: `fields` is a sparse fieldset on the durable client surface, and
|
|
694
|
+
// an unsupported shape must not be silently ignored. A client token
|
|
695
|
+
// previously had its `fields` parsed and then discarded, so
|
|
696
|
+
// `?fields=id` returned every granted field — the response did
|
|
697
|
+
// something other than what was asked, with a 200.
|
|
698
|
+
//
|
|
699
|
+
// A client may only NARROW within its grant; it can never widen. So the
|
|
700
|
+
// request is validated against the granted set, then intersected with
|
|
701
|
+
// it, then the schema-required floor is re-added.
|
|
702
|
+
const fields = context.tokenKind === "client"
|
|
703
|
+
? narrowClientFields(requestedFields, scope.fields, declaration, scope.schemaRequiredFloor)
|
|
704
|
+
: requestedFields
|
|
705
|
+
? [...requestedFields]
|
|
706
|
+
: undefined;
|
|
707
|
+
const cursor = c.req.query("cursor");
|
|
708
|
+
if (c.req.query("changes_since") !== undefined) {
|
|
709
|
+
// `fields` is passed here for ELIGIBILITY narrowing only (spec §4:
|
|
710
|
+
// a record whose only change was to an unauthorized field must not
|
|
711
|
+
// appear as "changed" at all). The store returns each row's RAW,
|
|
712
|
+
// unprojected data regardless of `fields` — response-shaping
|
|
713
|
+
// projection happens only at toRecordJson below, after
|
|
714
|
+
// recordWithinGrantTimeConstraint has already run against the real
|
|
715
|
+
// field value. This split matters: if the store projected the
|
|
716
|
+
// returned data too, a grant whose fields exclude the
|
|
717
|
+
// time_constraint field would silently break that filter.
|
|
718
|
+
const page = deps.store.changesSince(stream, {
|
|
719
|
+
instanceIds: effectiveInstanceIds,
|
|
720
|
+
changesSince: c.req.query("changes_since"),
|
|
721
|
+
cursor: c.req.query("cursor"),
|
|
722
|
+
limit,
|
|
723
|
+
fields,
|
|
724
|
+
});
|
|
725
|
+
const visible = page.data.filter((row) => {
|
|
726
|
+
if (row.deleted) {
|
|
727
|
+
return recordKeyWithinGrantResources(row.recordKey, scope.streamGrant);
|
|
728
|
+
}
|
|
729
|
+
return (recordKeyWithinGrantResources(row.recordKey, scope.streamGrant) &&
|
|
730
|
+
recordWithinGrantTimeConstraint(row.data, scope.streamGrant));
|
|
731
|
+
});
|
|
732
|
+
const meta = clamped
|
|
733
|
+
? {
|
|
734
|
+
warnings: [
|
|
735
|
+
{ code: "limit_clamped", message: "limit clamped to 100" },
|
|
736
|
+
],
|
|
737
|
+
}
|
|
738
|
+
: {};
|
|
739
|
+
return c.json({
|
|
740
|
+
object: "list",
|
|
741
|
+
has_more: page.hasMore,
|
|
742
|
+
...(page.nextCursor && { next_cursor: page.nextCursor }),
|
|
743
|
+
...(page.nextChangesSince && {
|
|
744
|
+
next_changes_since: page.nextChangesSince,
|
|
745
|
+
}),
|
|
746
|
+
data: visible.map((row) => toRecordJson(stream, row, fields)),
|
|
747
|
+
...(clamped && { meta }),
|
|
748
|
+
}, 200, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
749
|
+
}
|
|
750
|
+
// Same ordering requirement as the changes_since branch above: fetch
|
|
751
|
+
// unprojected, filter by time_constraint against the real value, then
|
|
752
|
+
// project fields only in toRecordJson.
|
|
753
|
+
// Grant filtering happens after the store pages, so a page of `limit`
|
|
754
|
+
// stored rows can yield fewer than `limit` visible rows. Returning that
|
|
755
|
+
// short page directly would make `limit` mean "at most N stored rows"
|
|
756
|
+
// rather than "at most N records you may see", and would report
|
|
757
|
+
// has_more/next_cursor for a position in the unfiltered sequence.
|
|
758
|
+
// Accumulate visible rows across store pages instead, then derive
|
|
759
|
+
// paging state from the filtered view.
|
|
760
|
+
const visible = [];
|
|
761
|
+
let pageCursor = cursor;
|
|
762
|
+
let storeHasMore = false;
|
|
763
|
+
let cursorTemplate;
|
|
764
|
+
do {
|
|
765
|
+
const requestCursor = pageCursor;
|
|
766
|
+
const page = deps.store.listRecords(stream, {
|
|
767
|
+
instanceIds: effectiveInstanceIds,
|
|
768
|
+
// Fetch at most one client page from the store at a time. The route
|
|
769
|
+
// may need several store pages to collect enough grant-visible rows,
|
|
770
|
+
// but keeping this page size at the client limit means the store
|
|
771
|
+
// usually gives us a bound cursor exactly at the visible boundary.
|
|
772
|
+
limit,
|
|
773
|
+
order,
|
|
774
|
+
cursor: requestCursor,
|
|
775
|
+
});
|
|
776
|
+
for (const row of page.data) {
|
|
777
|
+
if (recordKeyWithinGrantResources(row.recordKey, scope.streamGrant) &&
|
|
778
|
+
recordWithinGrantTimeConstraint(row.data, scope.streamGrant)) {
|
|
779
|
+
visible.push(row);
|
|
780
|
+
}
|
|
781
|
+
if (visible.length > limit)
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
pageCursor = page.nextCursor ?? undefined;
|
|
785
|
+
cursorTemplate = pageCursor ?? requestCursor;
|
|
786
|
+
storeHasMore = page.hasMore;
|
|
787
|
+
} while (visible.length <= limit && storeHasMore && pageCursor);
|
|
788
|
+
// limit + 1 visible rows means at least one more record the caller may
|
|
789
|
+
// see; trim it and report has_more from the filtered view.
|
|
790
|
+
const moreVisible = visible.length > limit;
|
|
791
|
+
const data = moreVisible ? visible.slice(0, limit) : visible;
|
|
792
|
+
const last = data[data.length - 1];
|
|
793
|
+
if (moreVisible && last && !cursorTemplate) {
|
|
794
|
+
throw new Error("Filtered list has more rows but no bound store cursor");
|
|
795
|
+
}
|
|
796
|
+
const meta = clamped
|
|
797
|
+
? {
|
|
798
|
+
warnings: [
|
|
799
|
+
{ code: "limit_clamped", message: "limit clamped to 100" },
|
|
800
|
+
],
|
|
801
|
+
}
|
|
802
|
+
: undefined;
|
|
803
|
+
const response = c.json({
|
|
804
|
+
object: "list",
|
|
805
|
+
has_more: moreVisible,
|
|
806
|
+
...(moreVisible &&
|
|
807
|
+
last &&
|
|
808
|
+
cursorTemplate && {
|
|
809
|
+
next_cursor: rebindListCursor(cursorTemplate, order, last),
|
|
810
|
+
}),
|
|
811
|
+
data: data.map((row) => toRecordJson(stream, row, fields)),
|
|
812
|
+
...(meta && { meta }),
|
|
813
|
+
}, 200, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
814
|
+
await logClientRead(c, context, stream, "completed", reqId);
|
|
815
|
+
return response;
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
const mapped = mapAndLog(deps, "GET /v1/streams/:stream/records", reqId, err);
|
|
819
|
+
// A refusal is an access event too: the §7 access feed must show denied
|
|
820
|
+
// attempts, not only successful ones, or an owner cannot see that an
|
|
821
|
+
// app tried to read something it was not granted.
|
|
822
|
+
await logClientRead(c, context, c.req.param("stream"), mapped.status === 500 ? "failed" : "denied", reqId);
|
|
823
|
+
return sendError(c, mapped, reqId);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
app.get("/streams/:stream/records/:id", async (c) => {
|
|
827
|
+
const reqId = requestId();
|
|
828
|
+
const { context, error } = await authenticate(c, reqId);
|
|
829
|
+
if (error)
|
|
830
|
+
return error;
|
|
831
|
+
try {
|
|
832
|
+
rejectUnknownParams(c, "getRecord", context.tokenKind);
|
|
833
|
+
// §8 requires client-token `expand[]` to be REJECTED before the
|
|
834
|
+
// declaration is consulted, on single-record reads as well as lists.
|
|
835
|
+
// Silently ignoring it told a client its request was honored when a
|
|
836
|
+
// narrower thing happened instead.
|
|
837
|
+
const stream = c.req.param("stream");
|
|
838
|
+
const recordKey = decodeURIComponent(c.req.param("id"));
|
|
839
|
+
const { declaration, ownerInstanceIds } = readDeclaration(context, stream);
|
|
840
|
+
const scope = resolveReadScope(context, stream, declaration);
|
|
841
|
+
const effectiveInstanceIds = context.tokenKind === "owner" ? ownerInstanceIds : scope.instanceIds;
|
|
842
|
+
if (context.tokenKind === "client") {
|
|
843
|
+
requireRegisteredConnections(effectiveInstanceIds);
|
|
844
|
+
}
|
|
845
|
+
if (!recordKeyWithinGrantResources(recordKey, scope.streamGrant)) {
|
|
846
|
+
throw new PdppError("not_found", "Record not found");
|
|
847
|
+
}
|
|
848
|
+
let found;
|
|
849
|
+
for (const instance of effectiveInstanceIds) {
|
|
850
|
+
const record = deps.store.getRecord(instance, stream, recordKey);
|
|
851
|
+
if (record) {
|
|
852
|
+
found = record;
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (!found ||
|
|
857
|
+
!recordWithinGrantTimeConstraint(found.data, scope.streamGrant)) {
|
|
858
|
+
throw new PdppError("not_found", "Record not found");
|
|
859
|
+
}
|
|
860
|
+
// `fields` is a declared parameter of this endpoint, and `v0.2-4-2`
|
|
861
|
+
// builds disclosed `data` from the grant AND the request-time
|
|
862
|
+
// selection. It was parsed nowhere, so `?fields=date` returned every
|
|
863
|
+
// granted field with a 200 — the response did something other than what
|
|
864
|
+
// was asked, and a client narrowing its own exposure did not get it.
|
|
865
|
+
const requestedFields = c.req
|
|
866
|
+
.query("fields")
|
|
867
|
+
?.split(",")
|
|
868
|
+
.map((f) => f.trim());
|
|
869
|
+
const fields = context.tokenKind === "client"
|
|
870
|
+
? narrowClientFields(requestedFields, scope.fields, declaration, scope.schemaRequiredFloor)
|
|
871
|
+
: (requestedFields ?? scope.fields);
|
|
872
|
+
const data = projectFields(found.data, fields);
|
|
873
|
+
const response = c.json({
|
|
874
|
+
object: "record",
|
|
875
|
+
id: recordKey,
|
|
876
|
+
stream,
|
|
877
|
+
data,
|
|
878
|
+
emitted_at: found.emittedAt,
|
|
879
|
+
}, 200, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
880
|
+
await logClientRead(c, context, stream, "completed", reqId);
|
|
881
|
+
return response;
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
const mapped = mapAndLog(deps, "GET /v1/streams/:stream/records/:id", reqId, err);
|
|
885
|
+
await logClientRead(c, context, c.req.param("stream"), mapped.status === 500 ? "failed" : "denied", reqId);
|
|
886
|
+
return sendError(c, mapped, reqId);
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
app.delete("/streams/:stream/records/:id", async (c) => {
|
|
890
|
+
const reqId = requestId();
|
|
891
|
+
const { context, error } = await authenticate(c, reqId);
|
|
892
|
+
if (error)
|
|
893
|
+
return error;
|
|
894
|
+
try {
|
|
895
|
+
rejectUnknownParams(c, "deleteRecord");
|
|
896
|
+
if (context.tokenKind !== "owner") {
|
|
897
|
+
throw new PdppError("authentication_error", "Delete requires an owner token");
|
|
898
|
+
}
|
|
899
|
+
const stream = c.req.param("stream");
|
|
900
|
+
const recordKey = decodeURIComponent(c.req.param("id"));
|
|
901
|
+
if (!deps.declarations.declares(stream)) {
|
|
902
|
+
throw new PdppError("not_found", "Stream not found");
|
|
903
|
+
}
|
|
904
|
+
const effectiveInstanceIds = ownerInstances(context);
|
|
905
|
+
let deletedAny = false;
|
|
906
|
+
for (const instance of effectiveInstanceIds) {
|
|
907
|
+
const declaration = deps.declarations.forInstance(instance, stream);
|
|
908
|
+
if (!declaration)
|
|
909
|
+
continue;
|
|
910
|
+
if (deps.store.deleteRecord(instance, stream, recordKey, new Date().toISOString(), declaration.semantics)) {
|
|
911
|
+
deletedAny = true;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (!deletedAny)
|
|
915
|
+
throw new PdppError("not_found", "Record not found");
|
|
916
|
+
return c.body(null, 204, {
|
|
917
|
+
"Request-Id": reqId,
|
|
918
|
+
"PDPP-Version": PDPP_VERSION,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
catch (err) {
|
|
922
|
+
return sendError(c, mapAndLog(deps, "DELETE /v1/streams/:stream/records/:id", reqId, err), reqId);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
// Owner-authenticated ingest. Not one of the six §8 read/delete endpoints —
|
|
926
|
+
// this lane's own write path so there is data to serve. Body is a single
|
|
927
|
+
// RECORD envelope or an array of envelopes; the whole call is one
|
|
928
|
+
// ingestBatch invocation (one SQLite transaction on the desktop backend).
|
|
929
|
+
app.post("/streams/:stream/records/ingest", async (c) => {
|
|
930
|
+
const reqId = requestId();
|
|
931
|
+
const { context, error } = await authenticate(c, reqId);
|
|
932
|
+
if (error)
|
|
933
|
+
return error;
|
|
934
|
+
try {
|
|
935
|
+
if (context.tokenKind !== "owner" ||
|
|
936
|
+
!context.subjectId ||
|
|
937
|
+
!deps.ownerSubjectId ||
|
|
938
|
+
context.subjectId.toLowerCase() !== deps.ownerSubjectId.toLowerCase()) {
|
|
939
|
+
throw new PdppError("authentication_error", "Ingest requires an owner token");
|
|
940
|
+
}
|
|
941
|
+
const stream = c.req.param("stream");
|
|
942
|
+
if (!deps.declarations.declares(stream)) {
|
|
943
|
+
throw new PdppError("not_found", "Stream not found");
|
|
944
|
+
}
|
|
945
|
+
const body = parseIngestBody(await readBoundedBody(c.req.raw, MAX_INGEST_BODY_BYTES, "Ingest"));
|
|
946
|
+
const method = c.req.query("method");
|
|
947
|
+
const rawGeneration = c.req.query("binding_generation");
|
|
948
|
+
const generation = rawGeneration === undefined ? NaN : Number(rawGeneration);
|
|
949
|
+
if (deps.bindingStore &&
|
|
950
|
+
(!method || !Number.isSafeInteger(generation) || generation < 1)) {
|
|
951
|
+
throw new PdppError("invalid_request", "method and positive binding_generation query parameters are required");
|
|
952
|
+
}
|
|
953
|
+
const entries = Array.isArray(body) ? body : [body];
|
|
954
|
+
// Each entry gets exactly one outcome, by input index. Entries that
|
|
955
|
+
// cannot reach the store are decided here; the rest go to the store in
|
|
956
|
+
// one batch and their outcomes are mapped back to input positions.
|
|
957
|
+
const results = new Array(entries.length);
|
|
958
|
+
const admitted = [];
|
|
959
|
+
const ownedInstances = ownerInstances(context);
|
|
960
|
+
entries.forEach((entry, index) => {
|
|
961
|
+
if (entry === null ||
|
|
962
|
+
typeof entry !== "object" ||
|
|
963
|
+
Array.isArray(entry)) {
|
|
964
|
+
results[index] = {
|
|
965
|
+
index,
|
|
966
|
+
outcome: "rejected",
|
|
967
|
+
reason: "envelope must be a JSON object",
|
|
968
|
+
};
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const e = entry;
|
|
972
|
+
if (typeof e.instance !== "string" ||
|
|
973
|
+
!ownedInstances.includes(e.instance)) {
|
|
974
|
+
throw new PdppError("authentication_error", "Ingest requires an owned instance");
|
|
975
|
+
}
|
|
976
|
+
if (deps.bindingStore && method) {
|
|
977
|
+
const configured = deps.configuredMethods?.get(e.instance) ?? [];
|
|
978
|
+
if (configured.length > 1) {
|
|
979
|
+
throw new PdppBindingError("config_multiple_active_methods");
|
|
980
|
+
}
|
|
981
|
+
if (configured.length !== 1 || configured[0] !== method) {
|
|
982
|
+
throw new PdppBindingError("method_inactive");
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (!deps.declarations.forInstance(e.instance, stream)) {
|
|
986
|
+
results[index] = {
|
|
987
|
+
index,
|
|
988
|
+
outcome: "rejected",
|
|
989
|
+
reason: `the source of instance '${e.instance}' does not declare stream '${stream}'`,
|
|
990
|
+
};
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
admitted.push({
|
|
994
|
+
index,
|
|
995
|
+
envelope: {
|
|
996
|
+
instance: e.instance,
|
|
997
|
+
stream,
|
|
998
|
+
key: e.key,
|
|
999
|
+
data: (e.data ?? null),
|
|
1000
|
+
emitted_at: e.emitted_at,
|
|
1001
|
+
op: e.op,
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
const declarationFor = (instance) => deps.declarations.forInstance(instance, stream);
|
|
1006
|
+
const stored = deps.store.ingestBatch(admitted.map((a) => a.envelope), (_stream, instance) => declarationFor(instance).semantics, (_stream, instance) => declarationFor(instance).primaryKey, deps.bindingStore && method ? { method, generation } : undefined, validateData);
|
|
1007
|
+
stored.results.forEach((result, position) => {
|
|
1008
|
+
const index = admitted[position].index;
|
|
1009
|
+
results[index] = { ...result, index };
|
|
1010
|
+
});
|
|
1011
|
+
const summary = summarizeIngest(results);
|
|
1012
|
+
return c.json({
|
|
1013
|
+
accepted: summary.accepted,
|
|
1014
|
+
unchanged: summary.unchanged,
|
|
1015
|
+
rejected: summary.rejected,
|
|
1016
|
+
results: summary.results,
|
|
1017
|
+
}, 200, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
1018
|
+
}
|
|
1019
|
+
catch (err) {
|
|
1020
|
+
if (err instanceof PdppBindingError) {
|
|
1021
|
+
return c.json({
|
|
1022
|
+
error: { code: err.reason, message: err.reason },
|
|
1023
|
+
request_id: reqId,
|
|
1024
|
+
}, 409, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
1025
|
+
}
|
|
1026
|
+
return sendError(c, mapAndLog(deps, "POST /v1/streams/:stream/records/ingest", reqId, err), reqId);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
// P7, owner-authenticated snapshot replace of one (instance, stream). A
|
|
1030
|
+
// Vana PS contract, not Core. Body: `{instance, emitted_at,
|
|
1031
|
+
// source_collected_at?, records[]}`; `records` is the complete live set from
|
|
1032
|
+
// a covered full refresh by the instance's bound method, and `emitted_at` is
|
|
1033
|
+
// the time stamped on the tombstone of each live key the snapshot lacks.
|
|
1034
|
+
// `source_collected_at` (RFC 3339) is when the producer collected that set;
|
|
1035
|
+
// with it, the replace also stores a completion receipt. One transaction: a
|
|
1036
|
+
// binding failure is 409, any rejected record is 422, and neither writes
|
|
1037
|
+
// anything.
|
|
1038
|
+
// It exists only under method authority, so a server without configured
|
|
1039
|
+
// methods does not mount it.
|
|
1040
|
+
if (deps.bindingStore) {
|
|
1041
|
+
const bindingStore = deps.bindingStore;
|
|
1042
|
+
app.post("/streams/:stream/records/replace", async (c) => {
|
|
1043
|
+
const reqId = requestId();
|
|
1044
|
+
const { context, error } = await authenticate(c, reqId);
|
|
1045
|
+
if (error)
|
|
1046
|
+
return error;
|
|
1047
|
+
const headers = { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION };
|
|
1048
|
+
try {
|
|
1049
|
+
if (context.tokenKind !== "owner" ||
|
|
1050
|
+
!context.subjectId ||
|
|
1051
|
+
!deps.ownerSubjectId ||
|
|
1052
|
+
context.subjectId.toLowerCase() !== deps.ownerSubjectId.toLowerCase()) {
|
|
1053
|
+
throw new PdppError("authentication_error", "Replace requires an owner token");
|
|
1054
|
+
}
|
|
1055
|
+
const stream = c.req.param("stream");
|
|
1056
|
+
if (!deps.declarations.declares(stream)) {
|
|
1057
|
+
throw new PdppError("not_found", "Stream not found");
|
|
1058
|
+
}
|
|
1059
|
+
const method = c.req.query("method");
|
|
1060
|
+
const generation = Number(c.req.query("binding_generation") ?? NaN);
|
|
1061
|
+
if (!method || !Number.isSafeInteger(generation) || generation < 1) {
|
|
1062
|
+
throw new PdppError("invalid_request", "method and positive binding_generation query parameters are required");
|
|
1063
|
+
}
|
|
1064
|
+
const body = parseIngestBody(await readBoundedBody(c.req.raw, MAX_INGEST_BODY_BYTES, "Ingest"));
|
|
1065
|
+
const { instance, emitted_at: emittedAt, source_collected_at: sourceCollectedAt, completion_policy: completionPolicy, records, } = body;
|
|
1066
|
+
if (Array.isArray(body) ||
|
|
1067
|
+
typeof instance !== "string" ||
|
|
1068
|
+
typeof emittedAt !== "string" ||
|
|
1069
|
+
!emittedAt ||
|
|
1070
|
+
!Array.isArray(records)) {
|
|
1071
|
+
throw new PdppError("invalid_request", "Replace body must be {instance, emitted_at, records[]}");
|
|
1072
|
+
}
|
|
1073
|
+
if (sourceCollectedAt !== undefined &&
|
|
1074
|
+
!isRfc3339DateTime(sourceCollectedAt)) {
|
|
1075
|
+
throw new PdppError("invalid_request", "source_collected_at must be an RFC 3339 date-time");
|
|
1076
|
+
}
|
|
1077
|
+
if (completionPolicy !== undefined &&
|
|
1078
|
+
(completionPolicy !== "preserve_if_unchanged" ||
|
|
1079
|
+
sourceCollectedAt !== undefined)) {
|
|
1080
|
+
throw new PdppError("invalid_request", 'completion_policy must be "preserve_if_unchanged" and cannot be sent with source_collected_at');
|
|
1081
|
+
}
|
|
1082
|
+
const ownedInstances = ownerInstances(context);
|
|
1083
|
+
if (!ownedInstances.includes(instance)) {
|
|
1084
|
+
throw new PdppError("authentication_error", "Replace requires an owned instance");
|
|
1085
|
+
}
|
|
1086
|
+
const configured = deps.configuredMethods?.get(instance) ?? [];
|
|
1087
|
+
if (configured.length > 1) {
|
|
1088
|
+
throw new PdppBindingError("config_multiple_active_methods");
|
|
1089
|
+
}
|
|
1090
|
+
if (configured.length !== 1 || configured[0] !== method) {
|
|
1091
|
+
throw new PdppBindingError("method_inactive");
|
|
1092
|
+
}
|
|
1093
|
+
const declaration = deps.declarations.forInstance(instance, stream);
|
|
1094
|
+
if (!declaration) {
|
|
1095
|
+
throw new PdppError("not_found", "Stream not found");
|
|
1096
|
+
}
|
|
1097
|
+
if (declaration.semantics !== "mutable_state") {
|
|
1098
|
+
throw new PdppError("invalid_request", "Replace requires a mutable_state stream");
|
|
1099
|
+
}
|
|
1100
|
+
const result = bindingStore.replaceStream({
|
|
1101
|
+
instance,
|
|
1102
|
+
stream,
|
|
1103
|
+
method,
|
|
1104
|
+
generation,
|
|
1105
|
+
emittedAt,
|
|
1106
|
+
primaryKey: declaration.primaryKey,
|
|
1107
|
+
validateData,
|
|
1108
|
+
...(sourceCollectedAt === undefined ? {} : { sourceCollectedAt }),
|
|
1109
|
+
...(completionPolicy === undefined
|
|
1110
|
+
? {}
|
|
1111
|
+
: { completionPolicy: "preserve_if_unchanged" }),
|
|
1112
|
+
envelopes: records.map((entry) => {
|
|
1113
|
+
const e = (entry !== null && typeof entry === "object" ? entry : {});
|
|
1114
|
+
return {
|
|
1115
|
+
instance,
|
|
1116
|
+
stream,
|
|
1117
|
+
key: e.key,
|
|
1118
|
+
data: (e.data ?? null),
|
|
1119
|
+
emitted_at: e.emitted_at,
|
|
1120
|
+
op: e.op,
|
|
1121
|
+
};
|
|
1122
|
+
}),
|
|
1123
|
+
});
|
|
1124
|
+
if (!result.applied) {
|
|
1125
|
+
return c.json({
|
|
1126
|
+
error: {
|
|
1127
|
+
type: "invalid_request_error",
|
|
1128
|
+
code: "invalid_request",
|
|
1129
|
+
message: "Replace rejected; nothing was written",
|
|
1130
|
+
},
|
|
1131
|
+
rejected: result.rejected,
|
|
1132
|
+
request_id: reqId,
|
|
1133
|
+
}, 422, headers);
|
|
1134
|
+
}
|
|
1135
|
+
return c.json({
|
|
1136
|
+
accepted: result.accepted,
|
|
1137
|
+
unchanged: result.unchanged,
|
|
1138
|
+
deleted: result.deleted,
|
|
1139
|
+
rejected: result.rejected,
|
|
1140
|
+
results: result.results,
|
|
1141
|
+
}, 200, headers);
|
|
1142
|
+
}
|
|
1143
|
+
catch (err) {
|
|
1144
|
+
if (err instanceof PdppBindingError) {
|
|
1145
|
+
return c.json({
|
|
1146
|
+
error: { code: err.reason, message: err.reason },
|
|
1147
|
+
request_id: reqId,
|
|
1148
|
+
}, 409, headers);
|
|
1149
|
+
}
|
|
1150
|
+
return sendError(c, mapAndLog(deps, "POST /v1/streams/:stream/records/replace", reqId, err), reqId);
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
// Owner-authenticated completion metadata: "this stream's live set is
|
|
1155
|
+
// exactly what the host's full-stream collection saw". It writes only
|
|
1156
|
+
// completion receipts (never records, history, arrival or the write
|
|
1157
|
+
// clock) and is mounted only over a store that can record them. Authority
|
|
1158
|
+
// is the replace route's: an owner token of the server owner, an owned
|
|
1159
|
+
// instance, the instance's one configured method. Primary keys come from
|
|
1160
|
+
// the loaded declarations, never from the body.
|
|
1161
|
+
if (deps.bindingStore?.completeStreams) {
|
|
1162
|
+
const bindingStore = deps.bindingStore;
|
|
1163
|
+
const completeStreams = deps.bindingStore.completeStreams;
|
|
1164
|
+
app.post("/records/completions", async (c) => {
|
|
1165
|
+
const reqId = requestId();
|
|
1166
|
+
const { context, error } = await authenticate(c, reqId);
|
|
1167
|
+
if (error)
|
|
1168
|
+
return error;
|
|
1169
|
+
const headers = { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION };
|
|
1170
|
+
try {
|
|
1171
|
+
if (context.tokenKind !== "owner" ||
|
|
1172
|
+
!context.subjectId ||
|
|
1173
|
+
!deps.ownerSubjectId ||
|
|
1174
|
+
context.subjectId.toLowerCase() !== deps.ownerSubjectId.toLowerCase()) {
|
|
1175
|
+
throw new PdppError("authentication_error", "Completion requires an owner token");
|
|
1176
|
+
}
|
|
1177
|
+
const method = c.req.query("method");
|
|
1178
|
+
const generation = Number(c.req.query("binding_generation") ?? NaN);
|
|
1179
|
+
if (!method || !Number.isSafeInteger(generation) || generation < 1) {
|
|
1180
|
+
throw new PdppError("invalid_request", "method and positive binding_generation query parameters are required");
|
|
1181
|
+
}
|
|
1182
|
+
let bytes;
|
|
1183
|
+
try {
|
|
1184
|
+
bytes = await readCompletionBody(c.req.raw);
|
|
1185
|
+
}
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
if (!(err instanceof CompletionBodyTooLarge))
|
|
1188
|
+
throw err;
|
|
1189
|
+
return c.json({
|
|
1190
|
+
error: {
|
|
1191
|
+
type: "invalid_request_error",
|
|
1192
|
+
code: "invalid_request",
|
|
1193
|
+
message: `Completion body exceeds the ${MAX_COMPLETION_BODY_BYTES} byte limit`,
|
|
1194
|
+
request_id: reqId,
|
|
1195
|
+
},
|
|
1196
|
+
}, 413, headers);
|
|
1197
|
+
}
|
|
1198
|
+
let body;
|
|
1199
|
+
try {
|
|
1200
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
throw new PdppError("invalid_request", "Completion body is not valid JSON");
|
|
1204
|
+
}
|
|
1205
|
+
const shapeError = () => new PdppError("invalid_request", "Completion body must be {source_id, instance, source_collected_at, collection_time_basis, streams[{stream, coverage, live_count, snapshot_sha256}]}");
|
|
1206
|
+
if (body === null ||
|
|
1207
|
+
typeof body !== "object" ||
|
|
1208
|
+
Array.isArray(body) ||
|
|
1209
|
+
!hasExactKeys(body, COMPLETION_BODY_KEYS)) {
|
|
1210
|
+
throw shapeError();
|
|
1211
|
+
}
|
|
1212
|
+
const wire = body;
|
|
1213
|
+
const streams = wire.streams;
|
|
1214
|
+
if (typeof wire.source_id !== "string" ||
|
|
1215
|
+
typeof wire.instance !== "string" ||
|
|
1216
|
+
typeof wire.source_collected_at !== "string" ||
|
|
1217
|
+
typeof wire.collection_time_basis !== "string" ||
|
|
1218
|
+
!Array.isArray(streams) ||
|
|
1219
|
+
streams.length === 0 ||
|
|
1220
|
+
// No source declares more streams than the registry holds.
|
|
1221
|
+
streams.length > deps.declarations.list().length ||
|
|
1222
|
+
!streams.every((entry) => entry !== null &&
|
|
1223
|
+
typeof entry === "object" &&
|
|
1224
|
+
!Array.isArray(entry) &&
|
|
1225
|
+
hasExactKeys(entry, COMPLETION_STREAM_KEYS) &&
|
|
1226
|
+
typeof entry.stream === "string" &&
|
|
1227
|
+
typeof entry.coverage === "string" &&
|
|
1228
|
+
typeof entry.live_count === "number" &&
|
|
1229
|
+
typeof entry.snapshot_sha256 === "string")) {
|
|
1230
|
+
throw shapeError();
|
|
1231
|
+
}
|
|
1232
|
+
// Authority. A source-scoped token is for that source alone, and the
|
|
1233
|
+
// instance must be one the token currently covers.
|
|
1234
|
+
if (context.sourceId && context.sourceId !== wire.source_id) {
|
|
1235
|
+
throw new PdppError("authentication_error", "Completion requires the source of the owner token");
|
|
1236
|
+
}
|
|
1237
|
+
if (!ownerInstances(context).includes(wire.instance)) {
|
|
1238
|
+
throw new PdppError("authentication_error", "Completion requires an owned instance");
|
|
1239
|
+
}
|
|
1240
|
+
const configured = deps.configuredMethods?.get(wire.instance) ?? [];
|
|
1241
|
+
if (configured.length > 1) {
|
|
1242
|
+
throw new PdppBindingError("config_multiple_active_methods");
|
|
1243
|
+
}
|
|
1244
|
+
if (configured.length !== 1 || configured[0] !== method) {
|
|
1245
|
+
throw new PdppBindingError("method_inactive");
|
|
1246
|
+
}
|
|
1247
|
+
if (bindingStore.getInstanceBinding(wire.instance).sourceId !==
|
|
1248
|
+
wire.source_id) {
|
|
1249
|
+
throw new PdppBindingError("source_mismatch");
|
|
1250
|
+
}
|
|
1251
|
+
const result = completeStreams({
|
|
1252
|
+
instance: wire.instance,
|
|
1253
|
+
sourceId: wire.source_id,
|
|
1254
|
+
method,
|
|
1255
|
+
generation,
|
|
1256
|
+
sourceCollectedAt: wire.source_collected_at,
|
|
1257
|
+
collectionTimeBasis: wire.collection_time_basis,
|
|
1258
|
+
streams: streams.map((entry) => ({
|
|
1259
|
+
stream: entry.stream,
|
|
1260
|
+
coverage: entry.coverage,
|
|
1261
|
+
liveCount: entry.live_count,
|
|
1262
|
+
snapshotSha256: entry.snapshot_sha256,
|
|
1263
|
+
})),
|
|
1264
|
+
}, (sourceId, stream) => deps.declarations.get(stream, sourceId)?.primaryKey);
|
|
1265
|
+
return c.json({
|
|
1266
|
+
receipts: result.receipts.map((receipt) => ({
|
|
1267
|
+
instance: receipt.instance,
|
|
1268
|
+
stream: receipt.stream,
|
|
1269
|
+
method: receipt.method,
|
|
1270
|
+
generation: receipt.generation,
|
|
1271
|
+
source_collected_at: receipt.sourceCollectedAt,
|
|
1272
|
+
write_seq: receipt.writeSeq,
|
|
1273
|
+
live_count: receipt.liveCount,
|
|
1274
|
+
snapshot_sha256: receipt.snapshotSha256,
|
|
1275
|
+
collection_time_basis: receipt.collectionTimeBasis,
|
|
1276
|
+
})),
|
|
1277
|
+
}, 200, headers);
|
|
1278
|
+
}
|
|
1279
|
+
catch (err) {
|
|
1280
|
+
if (err instanceof PdppBindingError) {
|
|
1281
|
+
return c.json({
|
|
1282
|
+
error: { code: err.reason, message: err.reason },
|
|
1283
|
+
request_id: reqId,
|
|
1284
|
+
}, 409, headers);
|
|
1285
|
+
}
|
|
1286
|
+
if (err instanceof StreamCompletionError) {
|
|
1287
|
+
if (COMPLETION_CONFLICTS.includes(err.reason)) {
|
|
1288
|
+
return c.json({
|
|
1289
|
+
error: { code: err.reason, message: err.reason },
|
|
1290
|
+
request_id: reqId,
|
|
1291
|
+
}, 409, headers);
|
|
1292
|
+
}
|
|
1293
|
+
if (err.reason !== "invalid_declaration") {
|
|
1294
|
+
return sendError(c, new PdppError("invalid_request", err.reason), reqId);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return sendError(c, mapAndLog(deps, "POST /v1/records/completions", reqId, err), reqId);
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
// Owner-authenticated blob ingest, the write half of GET /v1/blobs/:blob_id.
|
|
1302
|
+
//
|
|
1303
|
+
// The store has always been able to hold blob bytes and the read route has
|
|
1304
|
+
// always been able to serve them, but nothing could put bytes in over HTTP:
|
|
1305
|
+
// the only writers were in-process test fixtures. So a deployment could
|
|
1306
|
+
// implement the whole §8 blob surface and still have no blob for anyone to
|
|
1307
|
+
// fetch, which is not a hypothetical -- it is why the conformance suite's
|
|
1308
|
+
// three RS-1 blob cases report `skip` against this server today. They need a
|
|
1309
|
+
// persisted blob and a record referencing it, and declined to fabricate
|
|
1310
|
+
// either.
|
|
1311
|
+
//
|
|
1312
|
+
// Owner-only for the same reason ingest above is: a client that could upload
|
|
1313
|
+
// bytes could plant a blob and then read it back through its own grant.
|
|
1314
|
+
// Body is the raw bytes; Content-Type is the declared media type, which is
|
|
1315
|
+
// what the read route later serves back.
|
|
1316
|
+
app.post("/blobs/ingest", async (c) => {
|
|
1317
|
+
const reqId = requestId();
|
|
1318
|
+
const { context, error } = await authenticate(c, reqId);
|
|
1319
|
+
if (error)
|
|
1320
|
+
return error;
|
|
1321
|
+
try {
|
|
1322
|
+
if (context.tokenKind !== "owner" ||
|
|
1323
|
+
!context.subjectId ||
|
|
1324
|
+
!deps.ownerSubjectId ||
|
|
1325
|
+
context.subjectId.toLowerCase() !==
|
|
1326
|
+
deps.ownerSubjectId.toLowerCase() ||
|
|
1327
|
+
ownerInstances(context).length === 0) {
|
|
1328
|
+
throw new PdppError("authentication_error", "Blob ingest requires an owner token");
|
|
1329
|
+
}
|
|
1330
|
+
const mimeType = blobMediaType(c.req.header("content-type"));
|
|
1331
|
+
const instance = c.req.query("instance");
|
|
1332
|
+
const method = c.req.query("method");
|
|
1333
|
+
const rawGeneration = c.req.query("binding_generation");
|
|
1334
|
+
const generation = rawGeneration === undefined ? NaN : Number(rawGeneration);
|
|
1335
|
+
if (deps.bindingStore &&
|
|
1336
|
+
(!instance || !ownerInstances(context).includes(instance))) {
|
|
1337
|
+
throw new PdppError("authentication_error", "Blob ingest requires an owned instance");
|
|
1338
|
+
}
|
|
1339
|
+
if (deps.bindingStore &&
|
|
1340
|
+
(!method || !Number.isSafeInteger(generation) || generation < 1)) {
|
|
1341
|
+
throw new PdppError("invalid_request", "instance, method, and positive binding_generation query parameters are required");
|
|
1342
|
+
}
|
|
1343
|
+
if (deps.bindingStore && method) {
|
|
1344
|
+
const configured = deps.configuredMethods?.get(instance) ?? [];
|
|
1345
|
+
if (configured.length > 1) {
|
|
1346
|
+
throw new PdppBindingError("config_multiple_active_methods");
|
|
1347
|
+
}
|
|
1348
|
+
if (configured.length !== 1 || configured[0] !== method) {
|
|
1349
|
+
throw new PdppBindingError("method_inactive");
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const bytes = await readBoundedBody(c.req.raw, MAX_BLOB_UPLOAD_BYTES, "Blob");
|
|
1353
|
+
const meta = deps.bindingStore && method
|
|
1354
|
+
? deps.bindingStore.storeBlobBytesForInstance({
|
|
1355
|
+
instance: instance,
|
|
1356
|
+
method,
|
|
1357
|
+
generation,
|
|
1358
|
+
bytes,
|
|
1359
|
+
mimeType,
|
|
1360
|
+
})
|
|
1361
|
+
: deps.store.storeBlobBytes(bytes, mimeType);
|
|
1362
|
+
return c.json({
|
|
1363
|
+
blob_id: meta.blobId,
|
|
1364
|
+
mime_type: meta.mimeType,
|
|
1365
|
+
size_bytes: meta.sizeBytes,
|
|
1366
|
+
sha256: meta.sha256,
|
|
1367
|
+
}, 200, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
1368
|
+
}
|
|
1369
|
+
catch (err) {
|
|
1370
|
+
if (err instanceof PdppBindingError) {
|
|
1371
|
+
return c.json({
|
|
1372
|
+
error: { code: err.reason, message: err.reason },
|
|
1373
|
+
request_id: reqId,
|
|
1374
|
+
}, 409, { "Request-Id": reqId, "PDPP-Version": PDPP_VERSION });
|
|
1375
|
+
}
|
|
1376
|
+
return sendError(c, mapAndLog(deps, "POST /v1/blobs/ingest", reqId, err), reqId);
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
return app;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Applies the grant's field projection to a record's data for the response
|
|
1383
|
+
* body. Callers MUST filter by time_constraint against the record's
|
|
1384
|
+
* unprojected data BEFORE calling this — projecting first would strip the
|
|
1385
|
+
* time_constraint field before it can be compared, silently breaking the
|
|
1386
|
+
* filter for any grant whose fields don't happen to include that field.
|
|
1387
|
+
*/
|
|
1388
|
+
function projectFields(data, fields) {
|
|
1389
|
+
if (!fields)
|
|
1390
|
+
return data;
|
|
1391
|
+
const projected = {};
|
|
1392
|
+
for (const field of fields) {
|
|
1393
|
+
if (field in data)
|
|
1394
|
+
projected[field] = data[field];
|
|
1395
|
+
}
|
|
1396
|
+
return projected;
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* How many grant-visible records a client-token stream listing will count
|
|
1400
|
+
* before it stops counting and says so.
|
|
1401
|
+
*
|
|
1402
|
+
* `GET /v1/streams` is a metadata endpoint, and the grant predicate cannot be
|
|
1403
|
+
* pushed into the store today (`time_constraint` is evaluated in JS against
|
|
1404
|
+
* each record's data), so an exact count costs a full scan plus a JSON parse
|
|
1405
|
+
* per row. Letting that run unbounded makes the cheap metadata call strictly
|
|
1406
|
+
* more expensive than the data call it summarizes — a request-amplification
|
|
1407
|
+
* lever, multiplied by the number of streams in the grant.
|
|
1408
|
+
*
|
|
1409
|
+
* So the count is capped. Past the cap the response reports `record_count:
|
|
1410
|
+
* null` and a `meta.warnings` entry rather than a confidently wrong number:
|
|
1411
|
+
* an approximate count presented as exact is worse than an explicit "not
|
|
1412
|
+
* counted", because a client cannot tell it was truncated.
|
|
1413
|
+
*/
|
|
1414
|
+
const STREAM_COUNT_CAP = 1_000;
|
|
1415
|
+
/**
|
|
1416
|
+
* Rows either walk may scan before giving up, shared across both walks.
|
|
1417
|
+
*
|
|
1418
|
+
* The budget is in ROWS, not pages. An earlier version bounded the count walk
|
|
1419
|
+
* at 1000 *pages* of 100 rows — 100,000 rows — which made a filtering grant
|
|
1420
|
+
* the slow path: a `time_constraint` excluding the newest records forced the
|
|
1421
|
+
* `last_updated` walk through every page looking for its first visible row.
|
|
1422
|
+
* Measured 25s at 100k rows and 38s at 150k, against 137ms for an
|
|
1423
|
+
* all-visible grant at 60k. One request, a normal grant, a time window
|
|
1424
|
+
* excluding recent records.
|
|
1425
|
+
*/
|
|
1426
|
+
const STREAM_SCAN_ROW_BUDGET = 10_000;
|
|
1427
|
+
/**
|
|
1428
|
+
* Summarize the records a grant exposes in one stream, for a client-token
|
|
1429
|
+
* stream listing.
|
|
1430
|
+
*
|
|
1431
|
+
* Both walks are bounded by the same row budget, because both are adversarial
|
|
1432
|
+
* in the same way: the grant decides which rows are visible, so a grant that
|
|
1433
|
+
* hides the newest records makes `last_updated` expensive exactly as a grant
|
|
1434
|
+
* with many visible records makes the count expensive. Neither may turn a
|
|
1435
|
+
* metadata call into a full scan.
|
|
1436
|
+
*
|
|
1437
|
+
* When a walk exhausts its budget the caller reports null plus a warning. A
|
|
1438
|
+
* confident `0`/`null` after an incomplete scan would be indistinguishable
|
|
1439
|
+
* from a genuinely empty grant, which is the failure this bound exists to
|
|
1440
|
+
* avoid.
|
|
1441
|
+
*
|
|
1442
|
+
* Rows stay unprojected throughout: the `time_constraint` must be evaluated
|
|
1443
|
+
* against the record's real value, not a projected one.
|
|
1444
|
+
*/
|
|
1445
|
+
function summarizeGrantVisible(store, stream, instanceIds, streamGrant) {
|
|
1446
|
+
// `listRecords` already excludes tombstones, so a deleted row should not
|
|
1447
|
+
// appear here; the guard keeps the narrowing honest rather than asserting.
|
|
1448
|
+
const visible = (row) => !row.deleted &&
|
|
1449
|
+
recordKeyWithinGrantResources(row.recordKey, streamGrant) &&
|
|
1450
|
+
recordWithinGrantTimeConstraint(row.data, streamGrant);
|
|
1451
|
+
// Newest-first until the first visible row: that row's emittedAt is the
|
|
1452
|
+
// grant-visible `last_updated`, and usually it is on the first page.
|
|
1453
|
+
let lastUpdated = null;
|
|
1454
|
+
let cursor;
|
|
1455
|
+
let scanned = 0;
|
|
1456
|
+
let lastUpdatedExhausted = true;
|
|
1457
|
+
outer: while (scanned < STREAM_SCAN_ROW_BUDGET) {
|
|
1458
|
+
const page = store.listRecords(stream, {
|
|
1459
|
+
instanceIds,
|
|
1460
|
+
limit: MAX_LIMIT,
|
|
1461
|
+
order: "desc",
|
|
1462
|
+
cursor,
|
|
1463
|
+
});
|
|
1464
|
+
for (const row of page.data) {
|
|
1465
|
+
scanned++;
|
|
1466
|
+
if (visible(row)) {
|
|
1467
|
+
lastUpdated = row.emittedAt;
|
|
1468
|
+
lastUpdatedExhausted = false;
|
|
1469
|
+
break outer;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
if (!page.hasMore || !page.nextCursor) {
|
|
1473
|
+
// Reached the end of the stream: "no visible rows" is a real answer,
|
|
1474
|
+
// not a truncated one.
|
|
1475
|
+
lastUpdatedExhausted = false;
|
|
1476
|
+
break;
|
|
1477
|
+
}
|
|
1478
|
+
cursor = page.nextCursor;
|
|
1479
|
+
}
|
|
1480
|
+
// Count forward, stopping at the cap or the budget, whichever comes first.
|
|
1481
|
+
let count = 0;
|
|
1482
|
+
let capped = false;
|
|
1483
|
+
let countExhausted = false;
|
|
1484
|
+
scanned = 0;
|
|
1485
|
+
cursor = undefined;
|
|
1486
|
+
for (;;) {
|
|
1487
|
+
const page = store.listRecords(stream, {
|
|
1488
|
+
instanceIds,
|
|
1489
|
+
limit: MAX_LIMIT,
|
|
1490
|
+
order: "asc",
|
|
1491
|
+
cursor,
|
|
1492
|
+
});
|
|
1493
|
+
for (const row of page.data) {
|
|
1494
|
+
scanned++;
|
|
1495
|
+
if (visible(row))
|
|
1496
|
+
count++;
|
|
1497
|
+
if (count > STREAM_COUNT_CAP) {
|
|
1498
|
+
capped = true;
|
|
1499
|
+
break;
|
|
1500
|
+
}
|
|
1501
|
+
if (scanned >= STREAM_SCAN_ROW_BUDGET) {
|
|
1502
|
+
// Budget spent before the cap: we cannot claim this count is exact.
|
|
1503
|
+
countExhausted = true;
|
|
1504
|
+
break;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (capped || countExhausted || !page.hasMore || !page.nextCursor)
|
|
1508
|
+
break;
|
|
1509
|
+
cursor = page.nextCursor;
|
|
1510
|
+
}
|
|
1511
|
+
const incomplete = capped || countExhausted || lastUpdatedExhausted;
|
|
1512
|
+
return {
|
|
1513
|
+
recordCount: capped || countExhausted ? null : count,
|
|
1514
|
+
lastUpdated,
|
|
1515
|
+
budgetExhausted: incomplete,
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
function toRecordJson(stream, row, fields) {
|
|
1519
|
+
if (row.deleted) {
|
|
1520
|
+
return {
|
|
1521
|
+
object: "record",
|
|
1522
|
+
id: row.recordKey,
|
|
1523
|
+
stream,
|
|
1524
|
+
deleted: true,
|
|
1525
|
+
deleted_at: row.deletedAt,
|
|
1526
|
+
emitted_at: row.emittedAt,
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
return {
|
|
1530
|
+
object: "record",
|
|
1531
|
+
id: row.recordKey,
|
|
1532
|
+
stream,
|
|
1533
|
+
data: projectFields(row.data, fields),
|
|
1534
|
+
emitted_at: row.emittedAt,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
//# sourceMappingURL=pdpp-records.js.map
|