@nihilium/recovery-service 0.0.0-stage → 0.9.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/README.md +79 -2
- package/dist/index.d.ts +118 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +164 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -4
package/README.md
CHANGED
|
@@ -1,3 +1,80 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@nihilium/recovery-service`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The host half of value decomposition (spec §12). A wallet provider runs this and holds a recovery
|
|
4
|
+
vault's **encrypted records**; the seal that makes them meaningful stays with the user.
|
|
5
|
+
|
|
6
|
+
What the provider holds is inert on its own, so it has no unilateral access path — which is what
|
|
7
|
+
lets the same party hold operational controls, the §6 pause key included, without holding custody.
|
|
8
|
+
|
|
9
|
+
## What it deliberately cannot do
|
|
10
|
+
|
|
11
|
+
- **List.** There is no listing route and no `listRecords` on `SealedDataStore`. A listing would make
|
|
12
|
+
the provider a directory of who has a recovery.
|
|
13
|
+
- **Distinguish an empty record from an absent one.** An unknown id returns `{ entries: [] }`, not a
|
|
14
|
+
404, so a prober cannot enumerate.
|
|
15
|
+
- **Read what it holds.** The record payload is adapter-owned ciphertext and is never inspected —
|
|
16
|
+
including its format tag, so a condition adapter written next year works against a service
|
|
17
|
+
deployed today.
|
|
18
|
+
|
|
19
|
+
## Node-only by resolution
|
|
20
|
+
|
|
21
|
+
The `exports` map declares `types` and `node` and deliberately no `browser` or `default` condition,
|
|
22
|
+
mirroring `@nihilium/client-sdk`'s `./server` entry. A wallet bundle that imports this **fails to
|
|
23
|
+
resolve** rather than quietly shipping the provider's storage side to the client.
|
|
24
|
+
|
|
25
|
+
## Capabilities
|
|
26
|
+
|
|
27
|
+
| | Who holds it | Mailable |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `recordId` | the user, the provider, anyone | **yes** — that is what it is for |
|
|
30
|
+
| append credential | the wallet, beside the seal | **no** — it is a write capability |
|
|
31
|
+
| the seal | the user only | **no** — it is bearer material |
|
|
32
|
+
|
|
33
|
+
Reading needs only the record id: 120 unguessable bits yielding ciphertext. Appending needs a
|
|
34
|
+
separate credential, because an add-only store cannot delete — a read capability that also wrote
|
|
35
|
+
would make a leaked email a permanent junk-injection vector.
|
|
36
|
+
|
|
37
|
+
## Wiring it up
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createServer } from "node:http";
|
|
41
|
+
import { LocalSealedDataStore } from "@nihilium/recovery-storage-local";
|
|
42
|
+
import { RecoveryDataService, hashCredential, sharedSecretAppend } from "@nihilium/recovery-service";
|
|
43
|
+
|
|
44
|
+
const service = new RecoveryDataService({
|
|
45
|
+
store: new LocalSealedDataStore({ directory: "/var/lib/nihilium", domain: "acme-wallet" }),
|
|
46
|
+
append: sharedSecretAppend({ [recordId]: hashCredential(credential) }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
createServer(async (req, res) => {
|
|
50
|
+
const chunks: Buffer[] = [];
|
|
51
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
52
|
+
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : undefined;
|
|
53
|
+
|
|
54
|
+
const { status, body: out } = await service.handle({
|
|
55
|
+
method: req.method!,
|
|
56
|
+
path: new URL(req.url!, "http://localhost").pathname,
|
|
57
|
+
headers: req.headers as Record<string, string>,
|
|
58
|
+
body,
|
|
59
|
+
});
|
|
60
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
61
|
+
res.end(out === null ? "" : JSON.stringify(out));
|
|
62
|
+
}).listen(8080);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Embedding it in a framework you already run? Call `listEntries` and `appendEntry` directly —
|
|
66
|
+
`handle()` is a thin router over them, not the API.
|
|
67
|
+
|
|
68
|
+
## Routes
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
GET /records/:id -> 200 { entries } no credential
|
|
72
|
+
POST /records/:id -> 204 X-Nihilium-Append-Credential required
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## The failure mode to plan for
|
|
76
|
+
|
|
77
|
+
Losing these records loses the recovery even with the seal in hand. Because a record is inert
|
|
78
|
+
without the seal, there is no reason not to keep copies everywhere — keep one on the user's device
|
|
79
|
+
and treat the provider as a **replica, never the origin**. That is the exact inverse of the seal's
|
|
80
|
+
placement rule, and it is deliberate.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type SealedDataEntry, type SealedDataStore, type ServiceRequest, type ServiceResponse } from "@nihilium/recovery-core";
|
|
2
|
+
/**
|
|
3
|
+
* Re-exported so this package's surface is unchanged by the move into core.
|
|
4
|
+
*
|
|
5
|
+
* `ServiceError`, `hashCredential` and the constant-time compare are shared with
|
|
6
|
+
* `@nihilium/recovery-watchtower`: two hosts must not carry two copies of the same security
|
|
7
|
+
* primitive, since copies drift and nobody reading either one can see it.
|
|
8
|
+
*/
|
|
9
|
+
export { ServiceError, constantTimeEquals, hashCredential, readHeader, } from "@nihilium/recovery-core";
|
|
10
|
+
export type { ServiceRequest, ServiceResponse } from "@nihilium/recovery-core";
|
|
11
|
+
/**
|
|
12
|
+
* `@nihilium/recovery-service` — the host half of value decomposition (spec §12).
|
|
13
|
+
*
|
|
14
|
+
* A wallet provider runs this and holds a vault's encrypted records. What it holds is meaningless
|
|
15
|
+
* without the seal, which lives with the user, so the provider has **no unilateral access path** —
|
|
16
|
+
* which is exactly what lets the same party hold operational controls, the §6 pause key included,
|
|
17
|
+
* without ever holding custody.
|
|
18
|
+
*
|
|
19
|
+
* **Node-only, by resolution rather than by convention.** The package's `exports` map declares
|
|
20
|
+
* `types` and `node` and deliberately no `browser` or `default` condition, mirroring
|
|
21
|
+
* `@nihilium/client-sdk`'s `./server` entry. A wallet bundle that imports this fails to resolve
|
|
22
|
+
* instead of quietly shipping the provider's storage side to the client.
|
|
23
|
+
*
|
|
24
|
+
* There is no `node:http` here either. The package stays runtime-agnostic and a host wires
|
|
25
|
+
* `handle()` into whatever server it already runs — see the README for the five-line Node version.
|
|
26
|
+
*/
|
|
27
|
+
/** Decides whether an append may proceed. See `sharedSecretAppend` for the default. */
|
|
28
|
+
export interface AppendAuthorizer {
|
|
29
|
+
authorize(params: {
|
|
30
|
+
recordId: string;
|
|
31
|
+
credential: string | undefined;
|
|
32
|
+
}): Promise<void> | void;
|
|
33
|
+
}
|
|
34
|
+
export interface RecoveryDataServiceOptions {
|
|
35
|
+
store: SealedDataStore;
|
|
36
|
+
append: AppendAuthorizer;
|
|
37
|
+
/**
|
|
38
|
+
* Largest record accepted, in bytes of serialized JSON.
|
|
39
|
+
*
|
|
40
|
+
* A single-condition record is a few hundred bytes; an n-member quorum record is n times that
|
|
41
|
+
* plus manifest. 64 KiB is generous for both. Without a cap, an add-only store behind a bearer
|
|
42
|
+
* id is a free unbounded object store.
|
|
43
|
+
*/
|
|
44
|
+
maxEntryBytes?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Most entries one record may hold. A vault protecting more than a couple of hundred chains is
|
|
47
|
+
* not a thing; an append credential that leaked and is filling the record is.
|
|
48
|
+
*/
|
|
49
|
+
maxEntriesPerRecord?: number;
|
|
50
|
+
}
|
|
51
|
+
/** Header the default authorizer reads the append credential from. */
|
|
52
|
+
export declare const APPEND_CREDENTIAL_HEADER = "x-nihilium-append-credential";
|
|
53
|
+
/**
|
|
54
|
+
* The host.
|
|
55
|
+
*
|
|
56
|
+
* Two layers on purpose: `listEntries`/`appendEntry` are the semantic API and are what a host
|
|
57
|
+
* embedding this in its own framework should call, while `handle()` is a thin router over them for
|
|
58
|
+
* hosts that want the HTTP shape handed to them.
|
|
59
|
+
*/
|
|
60
|
+
export declare class RecoveryDataService {
|
|
61
|
+
private readonly store;
|
|
62
|
+
private readonly append;
|
|
63
|
+
private readonly maxEntryBytes;
|
|
64
|
+
private readonly maxEntriesPerRecord;
|
|
65
|
+
constructor(options: RecoveryDataServiceOptions);
|
|
66
|
+
/**
|
|
67
|
+
* Every entry under `recordId`.
|
|
68
|
+
*
|
|
69
|
+
* The record id **is** the read capability: 120 unguessable bits, and what it yields is
|
|
70
|
+
* ciphertext with no unilateral access path. No credential is required, which is what makes an
|
|
71
|
+
* id safe to mail to a user for later lookup.
|
|
72
|
+
*
|
|
73
|
+
* An unknown id returns an empty list rather than 404, deliberately. A prober must not be able
|
|
74
|
+
* to tell "this vault exists but is empty" from "no such vault" — the store behaves the same
|
|
75
|
+
* way, and a 404 here would undo it.
|
|
76
|
+
*/
|
|
77
|
+
listEntries(recordId: string): Promise<SealedDataEntry[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Append one record.
|
|
80
|
+
*
|
|
81
|
+
* The record id is deliberately **not** sufficient here. It is designed to be emailed, and an
|
|
82
|
+
* add-only store cannot delete, so a read capability that also wrote would be a permanent
|
|
83
|
+
* junk-injection vector with no way to clean up after it.
|
|
84
|
+
*/
|
|
85
|
+
appendEntry(recordId: string, entry: SealedDataEntry, credential?: string): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Route a request.
|
|
88
|
+
*
|
|
89
|
+
* Exactly two routes, and the absence of a third is a design decision rather than an omission:
|
|
90
|
+
* there is **no listing endpoint**. A listing would turn the service into a directory of who has
|
|
91
|
+
* a recovery, which is the one thing a provider holding only ciphertext must not become.
|
|
92
|
+
*
|
|
93
|
+
* GET /records/:id -> { entries }
|
|
94
|
+
* POST /records/:id -> 204, with an append credential
|
|
95
|
+
*/
|
|
96
|
+
handle(req: ServiceRequest): Promise<ServiceResponse>;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Appends are authorized by a credential the wallet minted at seal time and kept beside the seal.
|
|
100
|
+
*
|
|
101
|
+
* Only `sha256(credential)` is stored, so a compromise of the service's own state does not yield a
|
|
102
|
+
* write capability — the same commitment discipline the quorum uses for shares. Compared in constant
|
|
103
|
+
* time, since a timing oracle on a hash comparison is cheap to remove and awkward to reason about
|
|
104
|
+
* later.
|
|
105
|
+
*
|
|
106
|
+
* The credential is a *write* capability and must never be mailed the way a record id is.
|
|
107
|
+
*/
|
|
108
|
+
export declare function sharedSecretAppend(credentialHashes: Readonly<Record<string, string>>): AppendAuthorizer;
|
|
109
|
+
/**
|
|
110
|
+
* Lets anyone holding the record id append.
|
|
111
|
+
*
|
|
112
|
+
* Named to read badly in a configuration file, as `NihiliumPaymentProviderClientAPIKEY_DO_NOT_USE`
|
|
113
|
+
* is. Legitimate for the first-implementation case where the service and the seal sit on the same
|
|
114
|
+
* device and the record id never leaves it. Illegitimate the moment the id is mailed: it is designed
|
|
115
|
+
* to be, and an add-only store has no way to remove what a leaked id then writes.
|
|
116
|
+
*/
|
|
117
|
+
export declare function openAppend_DO_NOT_USE_IN_PRODUCTION(): AppendAuthorizer;
|
|
118
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAQH,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,eAAe,EACvB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;GAMG;AACH,OAAO,EACH,YAAY,EACZ,kBAAkB,EAClB,cAAc,EACd,UAAU,GACb,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE/E;;;;;;;;;;;;;;;GAeG;AAIH,uFAAuF;AACvF,MAAM,WAAW,gBAAgB;IAC7B,SAAS,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACjG;AAED,MAAM,WAAW,0BAA0B;IACvC,KAAK,EAAE,eAAe,CAAC;IACvB,MAAM,EAAE,gBAAgB,CAAC;IACzB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,sEAAsE;AACtE,eAAO,MAAM,wBAAwB,iCAAiC,CAAC;AAKvE;;;;;;GAMG;AACH,qBAAa,mBAAmB;IAC5B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkB;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmB;IAC1C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;gBAEjC,OAAO,EAAE,0BAA0B;IAO/C;;;;;;;;;;OAUG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAI/D;;;;;;OAMG;IACG,WAAW,CACb,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,eAAe,EACtB,UAAU,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,IAAI,CAAC;IAyBhB;;;;;;;;;OASG;IACG,MAAM,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC;CA+B9D;AAID;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAC9B,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACnD,gBAAgB,CAclB;AAED;;;;;;;GAOG;AACH,wBAAgB,mCAAmC,IAAI,gBAAgB,CAEtE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { RecoverySdkError, ServiceError, constantTimeEquals, describeEntryShapeProblem, hashCredential, normalizeRecordId, readHeader, } from "@nihilium/recovery-core";
|
|
2
|
+
/**
|
|
3
|
+
* Re-exported so this package's surface is unchanged by the move into core.
|
|
4
|
+
*
|
|
5
|
+
* `ServiceError`, `hashCredential` and the constant-time compare are shared with
|
|
6
|
+
* `@nihilium/recovery-watchtower`: two hosts must not carry two copies of the same security
|
|
7
|
+
* primitive, since copies drift and nobody reading either one can see it.
|
|
8
|
+
*/
|
|
9
|
+
export { ServiceError, constantTimeEquals, hashCredential, readHeader, } from "@nihilium/recovery-core";
|
|
10
|
+
/** Header the default authorizer reads the append credential from. */
|
|
11
|
+
export const APPEND_CREDENTIAL_HEADER = "x-nihilium-append-credential";
|
|
12
|
+
const DEFAULT_MAX_ENTRY_BYTES = 64 * 1024;
|
|
13
|
+
const DEFAULT_MAX_ENTRIES = 256;
|
|
14
|
+
/**
|
|
15
|
+
* The host.
|
|
16
|
+
*
|
|
17
|
+
* Two layers on purpose: `listEntries`/`appendEntry` are the semantic API and are what a host
|
|
18
|
+
* embedding this in its own framework should call, while `handle()` is a thin router over them for
|
|
19
|
+
* hosts that want the HTTP shape handed to them.
|
|
20
|
+
*/
|
|
21
|
+
export class RecoveryDataService {
|
|
22
|
+
store;
|
|
23
|
+
append;
|
|
24
|
+
maxEntryBytes;
|
|
25
|
+
maxEntriesPerRecord;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.store = options.store;
|
|
28
|
+
this.append = options.append;
|
|
29
|
+
this.maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
|
|
30
|
+
this.maxEntriesPerRecord = options.maxEntriesPerRecord ?? DEFAULT_MAX_ENTRIES;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every entry under `recordId`.
|
|
34
|
+
*
|
|
35
|
+
* The record id **is** the read capability: 120 unguessable bits, and what it yields is
|
|
36
|
+
* ciphertext with no unilateral access path. No credential is required, which is what makes an
|
|
37
|
+
* id safe to mail to a user for later lookup.
|
|
38
|
+
*
|
|
39
|
+
* An unknown id returns an empty list rather than 404, deliberately. A prober must not be able
|
|
40
|
+
* to tell "this vault exists but is empty" from "no such vault" — the store behaves the same
|
|
41
|
+
* way, and a 404 here would undo it.
|
|
42
|
+
*/
|
|
43
|
+
async listEntries(recordId) {
|
|
44
|
+
return this.store.getEntries(normalizeRecordId(recordId));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Append one record.
|
|
48
|
+
*
|
|
49
|
+
* The record id is deliberately **not** sufficient here. It is designed to be emailed, and an
|
|
50
|
+
* add-only store cannot delete, so a read capability that also wrote would be a permanent
|
|
51
|
+
* junk-injection vector with no way to clean up after it.
|
|
52
|
+
*/
|
|
53
|
+
async appendEntry(recordId, entry, credential) {
|
|
54
|
+
const id = normalizeRecordId(recordId);
|
|
55
|
+
await this.append.authorize({ recordId: id, credential });
|
|
56
|
+
assertEntryShape(entry);
|
|
57
|
+
const size = JSON.stringify(entry).length;
|
|
58
|
+
if (size > this.maxEntryBytes) {
|
|
59
|
+
throw new ServiceError(413, `Record is ${size} bytes; this service accepts at most ${this.maxEntryBytes}.`);
|
|
60
|
+
}
|
|
61
|
+
const existing = await this.store.getEntries(id);
|
|
62
|
+
if (existing.length >= this.maxEntriesPerRecord) {
|
|
63
|
+
throw new ServiceError(409, `This record already holds ${existing.length} entries, the configured maximum. ` +
|
|
64
|
+
`Entries are never removed, so this limit is final for this record.`);
|
|
65
|
+
}
|
|
66
|
+
await this.store.addEntry(id, entry);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Route a request.
|
|
70
|
+
*
|
|
71
|
+
* Exactly two routes, and the absence of a third is a design decision rather than an omission:
|
|
72
|
+
* there is **no listing endpoint**. A listing would turn the service into a directory of who has
|
|
73
|
+
* a recovery, which is the one thing a provider holding only ciphertext must not become.
|
|
74
|
+
*
|
|
75
|
+
* GET /records/:id -> { entries }
|
|
76
|
+
* POST /records/:id -> 204, with an append credential
|
|
77
|
+
*/
|
|
78
|
+
async handle(req) {
|
|
79
|
+
try {
|
|
80
|
+
const recordId = recordIdFromPath(req.path);
|
|
81
|
+
if (recordId === undefined) {
|
|
82
|
+
return { status: 404, body: { error: "No such route." } };
|
|
83
|
+
}
|
|
84
|
+
if (req.method.toUpperCase() === "GET") {
|
|
85
|
+
return { status: 200, body: { entries: await this.listEntries(recordId) } };
|
|
86
|
+
}
|
|
87
|
+
if (req.method.toUpperCase() === "POST") {
|
|
88
|
+
await this.appendEntry(recordId, req.body, readHeader(req, APPEND_CREDENTIAL_HEADER));
|
|
89
|
+
return { status: 204, body: null };
|
|
90
|
+
}
|
|
91
|
+
return { status: 405, body: { error: `${req.method} is not allowed here.` } };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error instanceof ServiceError) {
|
|
95
|
+
return { status: error.status, body: { error: error.message } };
|
|
96
|
+
}
|
|
97
|
+
if (error instanceof RecoverySdkError) {
|
|
98
|
+
// A malformed record id, a reused entry id, a store refusing a write. All of these
|
|
99
|
+
// are the caller's fault and all carry messages written to be read.
|
|
100
|
+
return { status: 400, body: { error: error.message } };
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// --- Append authorization -------------------------------------------------------------------------
|
|
107
|
+
/**
|
|
108
|
+
* Appends are authorized by a credential the wallet minted at seal time and kept beside the seal.
|
|
109
|
+
*
|
|
110
|
+
* Only `sha256(credential)` is stored, so a compromise of the service's own state does not yield a
|
|
111
|
+
* write capability — the same commitment discipline the quorum uses for shares. Compared in constant
|
|
112
|
+
* time, since a timing oracle on a hash comparison is cheap to remove and awkward to reason about
|
|
113
|
+
* later.
|
|
114
|
+
*
|
|
115
|
+
* The credential is a *write* capability and must never be mailed the way a record id is.
|
|
116
|
+
*/
|
|
117
|
+
export function sharedSecretAppend(credentialHashes) {
|
|
118
|
+
return {
|
|
119
|
+
authorize({ recordId, credential }) {
|
|
120
|
+
const expected = credentialHashes[recordId];
|
|
121
|
+
if (!expected) {
|
|
122
|
+
// Same status and message whether the record is unknown or the credential is wrong,
|
|
123
|
+
// so a prober cannot use append failures to enumerate records.
|
|
124
|
+
throw new ServiceError(403, "Not authorized to append to this record.");
|
|
125
|
+
}
|
|
126
|
+
if (credential === undefined || !constantTimeEquals(hashCredential(credential), expected)) {
|
|
127
|
+
throw new ServiceError(403, "Not authorized to append to this record.");
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Lets anyone holding the record id append.
|
|
134
|
+
*
|
|
135
|
+
* Named to read badly in a configuration file, as `NihiliumPaymentProviderClientAPIKEY_DO_NOT_USE`
|
|
136
|
+
* is. Legitimate for the first-implementation case where the service and the seal sit on the same
|
|
137
|
+
* device and the record id never leaves it. Illegitimate the moment the id is mailed: it is designed
|
|
138
|
+
* to be, and an add-only store has no way to remove what a leaked id then writes.
|
|
139
|
+
*/
|
|
140
|
+
export function openAppend_DO_NOT_USE_IN_PRODUCTION() {
|
|
141
|
+
return { authorize() { } };
|
|
142
|
+
}
|
|
143
|
+
// --- Internals ------------------------------------------------------------------------------------
|
|
144
|
+
/** `/records/:id`, with or without a trailing slash. Anything else is not a route. */
|
|
145
|
+
function recordIdFromPath(path) {
|
|
146
|
+
const parts = path.split("/").filter((part) => part !== "");
|
|
147
|
+
if (parts.length !== 2 || parts[0] !== "records")
|
|
148
|
+
return undefined;
|
|
149
|
+
return decodeURIComponent(parts[1]);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Check what the service can check without being able to read anything.
|
|
153
|
+
*
|
|
154
|
+
* The rule itself lives in core as `describeEntryShapeProblem`, because a client applies the same
|
|
155
|
+
* one to a *response* body where a `400` would be nonsense. What stays here is the service's error
|
|
156
|
+
* policy: the shape is a client's fault, so it is a `400`.
|
|
157
|
+
*/
|
|
158
|
+
function assertEntryShape(entry) {
|
|
159
|
+
const problem = describeEntryShapeProblem(entry);
|
|
160
|
+
if (problem !== undefined) {
|
|
161
|
+
throw new ServiceError(400, `Request body ${problem}.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,yBAAyB,EACzB,cAAc,EACd,iBAAiB,EACjB,UAAU,GAKb,MAAM,yBAAyB,CAAC;AAEjC;;;;;;GAMG;AACH,OAAO,EACH,YAAY,EACZ,kBAAkB,EAClB,cAAc,EACd,UAAU,GACb,MAAM,yBAAyB,CAAC;AA6CjC,sEAAsE;AACtE,MAAM,CAAC,MAAM,wBAAwB,GAAG,8BAA8B,CAAC;AAEvE,MAAM,uBAAuB,GAAG,EAAE,GAAG,IAAI,CAAC;AAC1C,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAEhC;;;;;;GAMG;AACH,MAAM,OAAO,mBAAmB;IACX,KAAK,CAAkB;IACvB,MAAM,CAAmB;IACzB,aAAa,CAAS;IACtB,mBAAmB,CAAS;IAE7C,YAAY,OAAmC;QAC3C,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;QAC3B,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;QAC7B,IAAI,CAAC,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,uBAAuB,CAAC;QACtE,IAAI,CAAC,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,IAAI,mBAAmB,CAAC;IAClF,CAAC;IAED;;;;;;;;;;OAUG;IACH,KAAK,CAAC,WAAW,CAAC,QAAgB;QAC9B,OAAO,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC;IAC9D,CAAC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,WAAW,CACb,QAAgB,EAChB,KAAsB,EACtB,UAAmB;QAEnB,MAAM,EAAE,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAC;QACvC,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE,UAAU,EAAE,CAAC,CAAC;QAE1D,gBAAgB,CAAC,KAAK,CAAC,CAAC;QACxB,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC;QAC1C,IAAI,IAAI,GAAG,IAAI,CAAC,aAAa,EAAE,CAAC;YAC5B,MAAM,IAAI,YAAY,CAClB,GAAG,EACH,aAAa,IAAI,wCAAwC,IAAI,CAAC,aAAa,GAAG,CACjF,CAAC;QACN,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;QACjD,IAAI,QAAQ,CAAC,MAAM,IAAI,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC9C,MAAM,IAAI,YAAY,CAClB,GAAG,EACH,6BAA6B,QAAQ,CAAC,MAAM,oCAAoC;gBAChF,oEAAoE,CACvE,CAAC;QACN,CAAC;QAED,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;IACzC,CAAC;IAED;;;;;;;;;OASG;IACH,KAAK,CAAC,MAAM,CAAC,GAAmB;QAC5B,IAAI,CAAC;YACD,MAAM,QAAQ,GAAG,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YAC5C,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;gBACzB,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,gBAAgB,EAAE,EAAE,CAAC;YAC9D,CAAC;YAED,IAAI,GAAG,CAAC,MAAM,CAAC,WAAW,EAAE,KAAK,KAAK,EAAE,CAAC;gBACrC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YAChF,CAAC;YACD,IAAI,GAAG,CAAC,MAAM,CAAC,WAAW,EAAE,KAAK,MAAM,EAAE,CAAC;gBACtC,MAAM,IAAI,CAAC,WAAW,CAClB,QAAQ,EACR,GAAG,CAAC,IAAuB,EAC3B,UAAU,CAAC,GAAG,EAAE,wBAAwB,CAAC,CAC5C,CAAC;gBACF,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;YACvC,CAAC;YACD,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC,MAAM,uBAAuB,EAAE,EAAE,CAAC;QAClF,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACb,IAAI,KAAK,YAAY,YAAY,EAAE,CAAC;gBAChC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;YACpE,CAAC;YACD,IAAI,KAAK,YAAY,gBAAgB,EAAE,CAAC;gBACpC,mFAAmF;gBACnF,oEAAoE;gBACpE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;YAC3D,CAAC;YACD,MAAM,KAAK,CAAC;QAChB,CAAC;IACL,CAAC;CACJ;AAED,qGAAqG;AAErG;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB,CAC9B,gBAAkD;IAElD,OAAO;QACH,SAAS,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE;YAC9B,MAAM,QAAQ,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC;YAC5C,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACZ,oFAAoF;gBACpF,+DAA+D;gBAC/D,MAAM,IAAI,YAAY,CAAC,GAAG,EAAE,0CAA0C,CAAC,CAAC;YAC5E,CAAC;YACD,IAAI,UAAU,KAAK,SAAS,IAAI,CAAC,kBAAkB,CAAC,cAAc,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,EAAE,CAAC;gBACxF,MAAM,IAAI,YAAY,CAAC,GAAG,EAAE,0CAA0C,CAAC,CAAC;YAC5E,CAAC;QACL,CAAC;KACJ,CAAC;AACN,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,mCAAmC;IAC/C,OAAO,EAAE,SAAS,KAAgC,CAAC,EAAE,CAAC;AAC1D,CAAC;AAED,qGAAqG;AAErG,sFAAsF;AACtF,SAAS,gBAAgB,CAAC,IAAY;IAClC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC;IAC5D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACnE,OAAO,kBAAkB,CAAC,KAAK,CAAC,CAAC,CAAE,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;GAMG;AACH,SAAS,gBAAgB,CAAC,KAAc;IACpC,MAAM,OAAO,GAAG,yBAAyB,CAAC,KAAK,CAAC,CAAC;IACjD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QACxB,MAAM,IAAI,YAAY,CAAC,GAAG,EAAE,gBAAgB,OAAO,GAAG,CAAC,CAAC;IAC5D,CAAC;AACL,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nihilium/recovery-service",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Node-only host for a recovery vault's encrypted records \u2014 value-decomposition storage a provider can run without holding custody (spec \u00a712).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"node": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -b",
|
|
21
|
+
"clean": "tsc -b --clean"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@nihilium/recovery-core": "^0.9.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@nihilium/recovery-storage-local": "^0.9.0"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|