@nacre.work/api 0.27.0 → 0.28.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.
@@ -0,0 +1,99 @@
1
+ import { type Redis } from '@nacre.work/core';
2
+ import type { AuthContext } from './auth.js';
3
+ /**
4
+ * Upload tickets: a document sent to the index without passing through a
5
+ * model's context window.
6
+ *
7
+ * An agent that holds a file cannot put it into `ingest_document`: a tool
8
+ * argument is a JSON string the model has to emit, which is the file retyped
9
+ * through the context window — paid for twice, and for anything a model
10
+ * cannot faithfully reproduce, not the same bytes. So the agent asks for a
11
+ * **ticket** instead, over MCP (`request_upload`) or REST
12
+ * (`POST /v1/uploads`), and whoever actually holds the bytes — a shell with
13
+ * `curl`, an MCP App's file input, a script — sends them to the ticket's URL.
14
+ * The index sees exactly the bytes that were on disk.
15
+ *
16
+ * The ticket is the capability, and that is what every property below is
17
+ * about:
18
+ *
19
+ * - **It is minted by a caller holding `write` on the layer**, checked at
20
+ * minting with the same resolve the ingest path makes, and the document is
21
+ * queued as that caller — the stored `AuthContext` — so the write is
22
+ * checked *again* when the bytes arrive. A grant revoked in between
23
+ * refuses the upload, which is invariant I4's "nothing waits for a cache".
24
+ * - **It is single-use.** `GETDEL` takes it out of the store in the same
25
+ * command that reads it, so two uploads racing on one ticket produce one
26
+ * document and one `404`.
27
+ * - **It expires in five minutes.** Long enough to open a terminal; short
28
+ * enough that a ticket in a log, a chat transcript or a screenshot is
29
+ * worth little. The TTL is the store's, not a field a caller sets.
30
+ * - **It names nothing a stranger can use.** The id is 32 random bytes; the
31
+ * endpoint that redeems it is the one place in the API that needs no
32
+ * credential, and it answers `404` for an unknown, spent or expired ticket
33
+ * alike — the same answer, so a probe learns nothing.
34
+ * - **It fails closed.** The store is Redis, and a Redis that does not
35
+ * answer refuses to mint and refuses to redeem. This is against the grain
36
+ * of the rate limiter beside it, which fails open; the difference is that
37
+ * this *is* an authorization control — a ticket is a bearer of somebody's
38
+ * `write` — and "could not check it, let it through" is the path this
39
+ * repository does not have.
40
+ *
41
+ * The descriptor the mint answers with is shaped after the one the MCP
42
+ * specification's file-transfer proposal (SEP-2631) has a server mint for an
43
+ * upload — `url`, `method`, `headers`, `expiresAt`, `maxSize` — so that when
44
+ * that proposal lands, `files/authorizeUpload` is a second door onto this
45
+ * store rather than a second implementation.
46
+ */
47
+ /** Five minutes, and the reason is in the header above. */
48
+ export declare const TICKET_TTL_SECONDS = 300;
49
+ /** What a ticket remembers: who asked, and what the document will be called. */
50
+ export interface UploadTicket {
51
+ readonly auth: AuthContext;
52
+ readonly layer: string;
53
+ readonly externalId?: string;
54
+ readonly title?: string;
55
+ readonly metadata?: Readonly<Record<string, unknown>>;
56
+ /** Unix seconds. Carried for the descriptor; the store's TTL is what enforces it. */
57
+ readonly expiresAt: number;
58
+ }
59
+ export interface UploadTicketStore {
60
+ /** Store a ticket under a fresh id and return the id. Throws when the store cannot answer. */
61
+ mint(ticket: UploadTicket): Promise<string>;
62
+ /** Take a ticket out of the store, or `undefined` when there is none. Throws when the store cannot answer. */
63
+ redeem(id: string): Promise<UploadTicket | undefined>;
64
+ }
65
+ /** The store, over the same Redis the rate limiter and the idempotency cache use. */
66
+ export declare class RedisUploadTickets implements UploadTicketStore {
67
+ private readonly redis;
68
+ constructor(redis: Redis);
69
+ mint(ticket: UploadTicket): Promise<string>;
70
+ redeem(id: string): Promise<UploadTicket | undefined>;
71
+ }
72
+ /** The descriptor the mint answers with, and the MCP tool hands to the model. */
73
+ export interface UploadDescriptor {
74
+ readonly ticket: string;
75
+ readonly url: string;
76
+ readonly method: 'POST';
77
+ readonly headers: Readonly<Record<string, string>>;
78
+ readonly expires_at: string;
79
+ readonly max_size: number;
80
+ readonly accepts: readonly string[];
81
+ readonly curl: string;
82
+ }
83
+ /**
84
+ * What a ticket id looks like on the wire, so a path that is not one is a
85
+ * `404` before the store is asked.
86
+ */
87
+ export declare const TICKET_SHAPE: RegExp;
88
+ /**
89
+ * The descriptor, built from the ticket and the base the deployment is
90
+ * reachable at.
91
+ *
92
+ * `curl` is the same request as a shell command, because the model that
93
+ * receives this descriptor hands it to a person or to a shell tool, and a
94
+ * line that can be pasted is the difference between an upload and a question.
95
+ * `--data-binary @file` sends the file as it is on disk — `-d` would strip
96
+ * newlines — and `--fail` turns a refusal into an exit code a script can read.
97
+ */
98
+ export declare function uploadDescriptor(ticket: string, expiresAt: number, baseUrl: string, maxBytes: number): UploadDescriptor;
99
+ //# sourceMappingURL=uploads.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uploads.d.ts","sourceRoot":"","sources":["../src/uploads.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkB,KAAK,KAAK,EAAE,MAAM,kBAAkB,CAAA;AAE7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAE5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,2DAA2D;AAC3D,eAAO,MAAM,kBAAkB,MAAM,CAAA;AAErC,gFAAgF;AAChF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACrD,qFAAqF;IACrF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,8FAA8F;IAC9F,IAAI,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAC3C,8GAA8G;IAC9G,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAA;CACtD;AAID,qFAAqF;AACrF,qBAAa,kBAAmB,YAAW,iBAAiB;IAC9C,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,KAAK;IAEnC,IAAI,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC;IAiB3C,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC;CAO5D;AAED,iFAAiF;AACjF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IAClD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,QAAwB,CAAA;AAEjD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,gBAAgB,CAYlB"}
@@ -0,0 +1,103 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { BINARY_FORMATS } from '@nacre.work/core';
3
+ /**
4
+ * Upload tickets: a document sent to the index without passing through a
5
+ * model's context window.
6
+ *
7
+ * An agent that holds a file cannot put it into `ingest_document`: a tool
8
+ * argument is a JSON string the model has to emit, which is the file retyped
9
+ * through the context window — paid for twice, and for anything a model
10
+ * cannot faithfully reproduce, not the same bytes. So the agent asks for a
11
+ * **ticket** instead, over MCP (`request_upload`) or REST
12
+ * (`POST /v1/uploads`), and whoever actually holds the bytes — a shell with
13
+ * `curl`, an MCP App's file input, a script — sends them to the ticket's URL.
14
+ * The index sees exactly the bytes that were on disk.
15
+ *
16
+ * The ticket is the capability, and that is what every property below is
17
+ * about:
18
+ *
19
+ * - **It is minted by a caller holding `write` on the layer**, checked at
20
+ * minting with the same resolve the ingest path makes, and the document is
21
+ * queued as that caller — the stored `AuthContext` — so the write is
22
+ * checked *again* when the bytes arrive. A grant revoked in between
23
+ * refuses the upload, which is invariant I4's "nothing waits for a cache".
24
+ * - **It is single-use.** `GETDEL` takes it out of the store in the same
25
+ * command that reads it, so two uploads racing on one ticket produce one
26
+ * document and one `404`.
27
+ * - **It expires in five minutes.** Long enough to open a terminal; short
28
+ * enough that a ticket in a log, a chat transcript or a screenshot is
29
+ * worth little. The TTL is the store's, not a field a caller sets.
30
+ * - **It names nothing a stranger can use.** The id is 32 random bytes; the
31
+ * endpoint that redeems it is the one place in the API that needs no
32
+ * credential, and it answers `404` for an unknown, spent or expired ticket
33
+ * alike — the same answer, so a probe learns nothing.
34
+ * - **It fails closed.** The store is Redis, and a Redis that does not
35
+ * answer refuses to mint and refuses to redeem. This is against the grain
36
+ * of the rate limiter beside it, which fails open; the difference is that
37
+ * this *is* an authorization control — a ticket is a bearer of somebody's
38
+ * `write` — and "could not check it, let it through" is the path this
39
+ * repository does not have.
40
+ *
41
+ * The descriptor the mint answers with is shaped after the one the MCP
42
+ * specification's file-transfer proposal (SEP-2631) has a server mint for an
43
+ * upload — `url`, `method`, `headers`, `expiresAt`, `maxSize` — so that when
44
+ * that proposal lands, `files/authorizeUpload` is a second door onto this
45
+ * store rather than a second implementation.
46
+ */
47
+ /** Five minutes, and the reason is in the header above. */
48
+ export const TICKET_TTL_SECONDS = 300;
49
+ const KEY = 'upload:';
50
+ /** The store, over the same Redis the rate limiter and the idempotency cache use. */
51
+ export class RedisUploadTickets {
52
+ redis;
53
+ constructor(redis) {
54
+ this.redis = redis;
55
+ }
56
+ async mint(ticket) {
57
+ const id = randomBytes(32).toString('base64url');
58
+ // `NX` so a collision — which 256 bits makes a theoretical concern and
59
+ // not a practical one — refuses rather than overwrites somebody else's
60
+ // ticket.
61
+ const reply = await this.redis.command('SET', `${KEY}${id}`, JSON.stringify(ticket), 'EX', String(TICKET_TTL_SECONDS), 'NX');
62
+ if (reply !== 'OK')
63
+ throw new Error('the upload ticket could not be stored');
64
+ return id;
65
+ }
66
+ async redeem(id) {
67
+ // One command: read and delete. Two would leave a window in which the
68
+ // same ticket is read twice, which is two documents from one capability.
69
+ const reply = await this.redis.command('GETDEL', `${KEY}${id}`);
70
+ if (reply === null || typeof reply !== 'string')
71
+ return undefined;
72
+ return JSON.parse(reply);
73
+ }
74
+ }
75
+ /**
76
+ * What a ticket id looks like on the wire, so a path that is not one is a
77
+ * `404` before the store is asked.
78
+ */
79
+ export const TICKET_SHAPE = /^[A-Za-z0-9_-]{43}$/;
80
+ /**
81
+ * The descriptor, built from the ticket and the base the deployment is
82
+ * reachable at.
83
+ *
84
+ * `curl` is the same request as a shell command, because the model that
85
+ * receives this descriptor hands it to a person or to a shell tool, and a
86
+ * line that can be pasted is the difference between an upload and a question.
87
+ * `--data-binary @file` sends the file as it is on disk — `-d` would strip
88
+ * newlines — and `--fail` turns a refusal into an exit code a script can read.
89
+ */
90
+ export function uploadDescriptor(ticket, expiresAt, baseUrl, maxBytes) {
91
+ const url = `${baseUrl.replace(/\/+$/, '')}/v1/uploads/${ticket}`;
92
+ return {
93
+ ticket,
94
+ url,
95
+ method: 'POST',
96
+ headers: { 'content-type': '<the file’s media type>' },
97
+ expires_at: new Date(expiresAt * 1000).toISOString(),
98
+ max_size: maxBytes,
99
+ accepts: ['text/plain', 'text/markdown', ...BINARY_FORMATS.map((f) => f.contentType)],
100
+ curl: `curl --fail --data-binary @FILE -H 'content-type: TYPE' '${url}'`,
101
+ };
102
+ }
103
+ //# sourceMappingURL=uploads.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uploads.js","sourceRoot":"","sources":["../src/uploads.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEzC,OAAO,EAAE,cAAc,EAAc,MAAM,kBAAkB,CAAA;AAI7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,2DAA2D;AAC3D,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA;AAoBrC,MAAM,GAAG,GAAG,SAAS,CAAA;AAErB,qFAAqF;AACrF,MAAM,OAAO,kBAAkB;IACA;IAA7B,YAA6B,KAAY;QAAZ,UAAK,GAAL,KAAK,CAAO;IAAG,CAAC;IAE7C,KAAK,CAAC,IAAI,CAAC,MAAoB;QAC7B,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;QAChD,uEAAuE;QACvE,uEAAuE;QACvE,UAAU;QACV,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,OAAO,CACpC,KAAK,EACL,GAAG,GAAG,GAAG,EAAE,EAAE,EACb,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EACtB,IAAI,EACJ,MAAM,CAAC,kBAAkB,CAAC,EAC1B,IAAI,CACL,CAAA;QACD,IAAI,KAAK,KAAK,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,uCAAuC,CAAC,CAAA;QAC5E,OAAO,EAAE,CAAA;IACX,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,EAAU;QACrB,sEAAsE;QACtE,yEAAyE;QACzE,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,EAAE,GAAG,GAAG,GAAG,EAAE,EAAE,CAAC,CAAA;QAC/D,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAA;QACjE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAiB,CAAA;IAC1C,CAAC;CACF;AAcD;;;GAGG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,qBAAqB,CAAA;AAEjD;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAAc,EACd,SAAiB,EACjB,OAAe,EACf,QAAgB;IAEhB,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,eAAe,MAAM,EAAE,CAAA;IACjE,OAAO;QACL,MAAM;QACN,GAAG;QACH,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,yBAAyB,EAAE;QACtD,UAAU,EAAE,IAAI,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE;QACpD,QAAQ,EAAE,QAAQ;QAClB,OAAO,EAAE,CAAC,YAAY,EAAE,eAAe,EAAE,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;QACrF,IAAI,EAAE,4DAA4D,GAAG,GAAG;KACzE,CAAA;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nacre.work/api",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
4
4
  "description": "Nacre REST API and authorization service",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "jose": "^6.2.12",
31
31
  "pg": "^8.23.0",
32
- "@nacre.work/core": "0.27.0"
32
+ "@nacre.work/core": "0.28.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^26.6.2",