@nacre.work/api 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.ts +6 -0
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +8 -0
- package/dist/adapters.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -1
- package/dist/server.d.ts +22 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +368 -109
- package/dist/server.js.map +1 -1
- package/dist/uploads.d.ts +99 -0
- package/dist/uploads.d.ts.map +1 -0
- package/dist/uploads.js +103 -0
- package/dist/uploads.js.map +1 -0
- package/package.json +2 -2
|
@@ -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"}
|
package/dist/uploads.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.28.0",
|
|
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.
|
|
32
|
+
"@nacre.work/core": "0.28.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^26.6.2",
|