@haverstack/server 0.8.0 → 0.9.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/app.d.ts +8 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +70 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +115 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changeStreams.d.ts +17 -0
- package/dist/lib/changeStreams.d.ts.map +1 -0
- package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
- package/dist/lib/changeStreams.js.map +1 -0
- package/dist/lib/frameGate.d.ts +26 -0
- package/dist/lib/frameGate.d.ts.map +1 -0
- package/dist/lib/frameGate.js +44 -0
- package/dist/lib/frameGate.js.map +1 -0
- package/dist/lib/json.d.ts +10 -0
- package/dist/lib/json.d.ts.map +1 -0
- package/dist/lib/json.js +27 -0
- package/dist/lib/json.js.map +1 -0
- package/dist/lib/nonceStore.d.ts +50 -0
- package/dist/lib/nonceStore.d.ts.map +1 -0
- package/dist/lib/nonceStore.js +96 -0
- package/dist/lib/nonceStore.js.map +1 -0
- package/dist/lib/queryLimit.d.ts +4 -0
- package/dist/lib/queryLimit.d.ts.map +1 -0
- package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
- package/dist/lib/queryLimit.js.map +1 -0
- package/dist/lib/queryWorker/pool.d.ts +60 -0
- package/dist/lib/queryWorker/pool.d.ts.map +1 -0
- package/dist/lib/queryWorker/pool.js +278 -0
- package/dist/lib/queryWorker/pool.js.map +1 -0
- package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
- package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
- package/dist/lib/queryWorker/protocol.js +2 -0
- package/dist/lib/queryWorker/protocol.js.map +1 -0
- package/dist/lib/queryWorker/worker.d.ts +2 -0
- package/dist/lib/queryWorker/worker.d.ts.map +1 -0
- package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
- package/dist/lib/queryWorker/worker.js.map +1 -0
- package/dist/lib/resumeBuffer.d.ts +143 -0
- package/dist/lib/resumeBuffer.d.ts.map +1 -0
- package/dist/lib/resumeBuffer.js +216 -0
- package/dist/lib/resumeBuffer.js.map +1 -0
- package/dist/lib/resumeCursor.d.ts +16 -0
- package/dist/lib/resumeCursor.d.ts.map +1 -0
- package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
- package/dist/lib/resumeCursor.js.map +1 -0
- package/dist/lib/tokenSweep.d.ts +17 -0
- package/dist/lib/tokenSweep.d.ts.map +1 -0
- package/dist/lib/tokenSweep.js +40 -0
- package/dist/lib/tokenSweep.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +46 -0
- package/dist/main.js.map +1 -0
- package/dist/middleware/auth.d.ts +25 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +74 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/errors.d.ts +17 -0
- package/dist/middleware/errors.d.ts.map +1 -0
- package/dist/middleware/errors.js +64 -0
- package/dist/middleware/errors.js.map +1 -0
- package/dist/routes/attachments.d.ts +5 -0
- package/dist/routes/attachments.d.ts.map +1 -0
- package/dist/routes/attachments.js +108 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +110 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/changes.d.ts +22 -0
- package/dist/routes/changes.d.ts.map +1 -0
- package/dist/routes/changes.js +233 -0
- package/dist/routes/changes.js.map +1 -0
- package/dist/routes/entity.d.ts +5 -0
- package/dist/routes/entity.d.ts.map +1 -0
- package/dist/routes/entity.js +30 -0
- package/dist/routes/entity.js.map +1 -0
- package/dist/routes/health.d.ts +4 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +7 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/records.d.ts +5 -0
- package/dist/routes/records.d.ts.map +1 -0
- package/dist/routes/records.js +231 -0
- package/dist/routes/records.js.map +1 -0
- package/dist/routes/tokens.d.ts +5 -0
- package/dist/routes/tokens.d.ts.map +1 -0
- package/dist/routes/tokens.js +65 -0
- package/dist/routes/tokens.js.map +1 -0
- package/dist/routes/types.d.ts +5 -0
- package/dist/routes/types.d.ts.map +1 -0
- package/dist/routes/types.js +46 -0
- package/dist/routes/types.js.map +1 -0
- package/dist/routes/wellknown.d.ts +6 -0
- package/dist/routes/wellknown.d.ts.map +1 -0
- package/dist/routes/wellknown.js +45 -0
- package/dist/routes/wellknown.js.map +1 -0
- package/dist/shutdown.d.ts +23 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +32 -0
- package/dist/shutdown.js.map +1 -0
- package/dist/stack.d.ts +20 -0
- package/dist/stack.d.ts.map +1 -0
- package/dist/stack.js +50 -0
- package/dist/stack.js.map +1 -0
- package/dist/testing.d.ts +49 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +130 -0
- package/dist/testing.js.map +1 -0
- package/{src/types.ts → dist/types.d.ts} +5 -5
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
- package/dist/wireError.d.ts.map +1 -0
- package/dist/wireError.js +13 -0
- package/dist/wireError.js.map +1 -0
- package/package.json +42 -17
- package/.claude/settings.local.json +0 -22
- package/.dockerignore +0 -10
- package/.env.example +0 -90
- package/.github/pull_request_template.md +0 -23
- package/.github/workflows/ci.yml +0 -55
- package/.github/workflows/release.yml +0 -99
- package/.prettierignore +0 -3
- package/.prettierrc +0 -7
- package/AGENTS.md +0 -91
- package/CONTRIBUTING.md +0 -234
- package/Dockerfile +0 -30
- package/SECURITY.md +0 -54
- package/docs/api.md +0 -277
- package/docs/deployment.md +0 -212
- package/eslint.config.mjs +0 -18
- package/pnpm-workspace.yaml +0 -13
- package/src/app.ts +0 -86
- package/src/config.ts +0 -176
- package/src/index.ts +0 -54
- package/src/lib/frameGate.ts +0 -42
- package/src/lib/json.ts +0 -27
- package/src/lib/nonceStore.ts +0 -103
- package/src/lib/queryWorker/pool.ts +0 -324
- package/src/lib/resumeBuffer.ts +0 -268
- package/src/lib/tokenSweep.ts +0 -43
- package/src/middleware/auth.ts +0 -77
- package/src/middleware/errors.ts +0 -73
- package/src/routes/attachments.ts +0 -126
- package/src/routes/auth.ts +0 -137
- package/src/routes/changes.ts +0 -279
- package/src/routes/entity.ts +0 -33
- package/src/routes/health.ts +0 -8
- package/src/routes/records.ts +0 -259
- package/src/routes/tokens.ts +0 -85
- package/src/routes/types.ts +0 -57
- package/src/routes/wellknown.ts +0 -55
- package/src/shutdown.ts +0 -56
- package/src/stack.ts +0 -96
- package/tests/app.test.ts +0 -86
- package/tests/changeFeedClient.ts +0 -156
- package/tests/config.test.ts +0 -112
- package/tests/conformance.test.ts +0 -2322
- package/tests/fixtures/blockingWorker.ts +0 -27
- package/tests/fixtures/brokenWorker.ts +0 -7
- package/tests/lib/changeFeedClient.test.ts +0 -126
- package/tests/lib/frameGate.test.ts +0 -93
- package/tests/lib/queryLimit.test.ts +0 -33
- package/tests/lib/queryWorker.test.ts +0 -60
- package/tests/lib/queryWorkerPool.test.ts +0 -129
- package/tests/lib/resumeBuffer.test.ts +0 -341
- package/tests/lib/resumeCursor.test.ts +0 -46
- package/tests/middleware/errors.test.ts +0 -176
- package/tests/routes/associations.test.ts +0 -170
- package/tests/routes/attachments.test.ts +0 -621
- package/tests/routes/auth.test.ts +0 -448
- package/tests/routes/changes.test.ts +0 -567
- package/tests/routes/contentQuery.test.ts +0 -68
- package/tests/routes/entity.test.ts +0 -170
- package/tests/routes/groups.test.ts +0 -69
- package/tests/routes/records.test.ts +0 -1965
- package/tests/routes/relatedToQuery.test.ts +0 -182
- package/tests/routes/tokens.test.ts +0 -157
- package/tests/routes/tombstones.test.ts +0 -93
- package/tests/routes/types.test.ts +0 -129
- package/tests/routes/versions.test.ts +0 -177
- package/tests/routes/wellknown.test.ts +0 -71
- package/tests/setup.ts +0 -154
- package/tests/shutdown.test.ts +0 -121
- package/tests/stack.test.ts +0 -198
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -12
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js';
|
|
3
|
-
|
|
4
|
-
const TYPE_ID = 'com.example.test/doc@1';
|
|
5
|
-
|
|
6
|
-
describe('Versions', () => {
|
|
7
|
-
let t: TestApp;
|
|
8
|
-
beforeEach(async () => {
|
|
9
|
-
t = await buildTestApp();
|
|
10
|
-
await t.ctx.stack.defineType(TYPE_ID, 'Doc', {
|
|
11
|
-
body: { kind: 'text' as const, required: true as const },
|
|
12
|
-
});
|
|
13
|
-
});
|
|
14
|
-
afterEach(async () => {
|
|
15
|
-
await t.cleanup();
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
async function createAndPatch() {
|
|
19
|
-
const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' });
|
|
20
|
-
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
21
|
-
token: TEST_TOKEN,
|
|
22
|
-
body: { contentPatch: { body: 'v2' } },
|
|
23
|
-
});
|
|
24
|
-
return record;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
it('GET /records/:id/versions returns history', async () => {
|
|
28
|
-
const record = await createAndPatch();
|
|
29
|
-
const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions`, {
|
|
30
|
-
token: TEST_TOKEN,
|
|
31
|
-
});
|
|
32
|
-
expect(status).toBe(200);
|
|
33
|
-
expect((data as unknown[]).length).toBe(1);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('GET /records/:id/versions/:version returns one version', async () => {
|
|
37
|
-
const record = await createAndPatch();
|
|
38
|
-
const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions/1`, {
|
|
39
|
-
token: TEST_TOKEN,
|
|
40
|
-
});
|
|
41
|
-
expect(status).toBe(200);
|
|
42
|
-
expect((data as Record<string, unknown>).version).toBe(1);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('POST /records/:id/restore/:version restores content without rewriting history', async () => {
|
|
46
|
-
const record = await createAndPatch();
|
|
47
|
-
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/restore/1`, {
|
|
48
|
-
token: TEST_TOKEN,
|
|
49
|
-
});
|
|
50
|
-
expect(status).toBe(200);
|
|
51
|
-
const d = data as Record<string, unknown>;
|
|
52
|
-
expect((d.content as Record<string, unknown>).body).toBe('v1');
|
|
53
|
-
// version 1 was snapshotted, then v2 was applied, then v2 was snapshotted for restore → new version is 3
|
|
54
|
-
expect(d.version).toBe(3);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe('malformed :version path param', () => {
|
|
58
|
-
it('GET /records/:id/versions/1abc is rejected rather than silently served as version 1', async () => {
|
|
59
|
-
const record = await createAndPatch();
|
|
60
|
-
const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions/1abc`, {
|
|
61
|
-
token: TEST_TOKEN,
|
|
62
|
-
});
|
|
63
|
-
expect(status).toBe(400);
|
|
64
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('POST /records/:id/restore/1abc is rejected rather than silently restoring version 1', async () => {
|
|
68
|
-
const record = await createAndPatch();
|
|
69
|
-
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/restore/1abc`, {
|
|
70
|
-
token: TEST_TOKEN,
|
|
71
|
-
});
|
|
72
|
-
expect(status).toBe(400);
|
|
73
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe('POST /records/:id/restore/:version — If-Match / optimistic concurrency', () => {
|
|
78
|
-
it('succeeds when If-Match names the current version', async () => {
|
|
79
|
-
const record = await createAndPatch();
|
|
80
|
-
const current = await t.ctx.adapter.getRecord(record.id);
|
|
81
|
-
const { status } = await req(t.app, 'POST', `/records/${record.id}/restore/1`, {
|
|
82
|
-
token: TEST_TOKEN,
|
|
83
|
-
headers: { 'If-Match': `"${current!.version}"` },
|
|
84
|
-
});
|
|
85
|
-
expect(status).toBe(200);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('returns 412 version_conflict on an If-Match mismatch', async () => {
|
|
89
|
-
const record = await createAndPatch();
|
|
90
|
-
const current = await t.ctx.adapter.getRecord(record.id);
|
|
91
|
-
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/restore/1`, {
|
|
92
|
-
token: TEST_TOKEN,
|
|
93
|
-
headers: { 'If-Match': `"${current!.version + 1}"` },
|
|
94
|
-
});
|
|
95
|
-
expect(status).toBe(412);
|
|
96
|
-
expect((data as { error: { code: string } }).error.code).toBe('version_conflict');
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
// Version history is now gated at the same level as update() — a
|
|
101
|
-
// write-holder, or owner/creator — not plain read, per
|
|
102
|
-
// docs/spec/versioning.md § History access.
|
|
103
|
-
describe('history access requires write, not read', () => {
|
|
104
|
-
async function seedShared(write: boolean) {
|
|
105
|
-
const record = await t.ctx.stack.create(
|
|
106
|
-
TYPE_ID,
|
|
107
|
-
{ body: 'v1' },
|
|
108
|
-
{
|
|
109
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write }],
|
|
110
|
-
},
|
|
111
|
-
);
|
|
112
|
-
await t.ctx.stack.patchContent(record.id, { body: 'v2' });
|
|
113
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
114
|
-
return { record, token };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
it('a read-only requester is refused GET /records/:id/versions', async () => {
|
|
118
|
-
const { record, token } = await seedShared(false);
|
|
119
|
-
const { status } = await req(t.app, 'GET', `/records/${record.id}/versions`, { token });
|
|
120
|
-
expect(status).toBe(403);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('a read-only requester is refused GET /records/:id/versions/:version', async () => {
|
|
124
|
-
const { record, token } = await seedShared(false);
|
|
125
|
-
const { status } = await req(t.app, 'GET', `/records/${record.id}/versions/1`, { token });
|
|
126
|
-
expect(status).toBe(403);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('a write-holder can still read version history', async () => {
|
|
130
|
-
const { record, token } = await seedShared(true);
|
|
131
|
-
const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions`, {
|
|
132
|
-
token,
|
|
133
|
-
});
|
|
134
|
-
expect(status).toBe(200);
|
|
135
|
-
expect((data as unknown[]).length).toBe(1);
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('strips permissions from a version snapshot for a non-owner write-holder, but keeps entityId', async () => {
|
|
140
|
-
const record = await t.ctx.stack.create(
|
|
141
|
-
TYPE_ID,
|
|
142
|
-
{ body: 'v1' },
|
|
143
|
-
{
|
|
144
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }],
|
|
145
|
-
},
|
|
146
|
-
);
|
|
147
|
-
await t.ctx.stack.patchContent(record.id, { body: 'v2' });
|
|
148
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
149
|
-
|
|
150
|
-
const { data } = await req(t.app, 'GET', `/records/${record.id}/versions`, { token });
|
|
151
|
-
const versions = data as Array<Record<string, unknown>>;
|
|
152
|
-
expect(versions[0].permissions).toBeUndefined();
|
|
153
|
-
|
|
154
|
-
const { data: ownerData } = await req(t.app, 'GET', `/records/${record.id}/versions`, {
|
|
155
|
-
token: TEST_TOKEN,
|
|
156
|
-
});
|
|
157
|
-
const ownerVersions = ownerData as Array<Record<string, unknown>>;
|
|
158
|
-
expect(ownerVersions[0].permissions).toBeDefined();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('bumps version on every mutation kind, not just content updates', async () => {
|
|
162
|
-
const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' });
|
|
163
|
-
expect(record.version).toBe(1);
|
|
164
|
-
|
|
165
|
-
await t.ctx.stack.associate(record.id, { kind: 'tag', label: 'starred' });
|
|
166
|
-
expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(2);
|
|
167
|
-
|
|
168
|
-
await t.ctx.stack.mutate(record.id, { permissions: [{ access: 'public' }] });
|
|
169
|
-
expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(3);
|
|
170
|
-
|
|
171
|
-
await t.ctx.stack.delete(record.id);
|
|
172
|
-
expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(4);
|
|
173
|
-
|
|
174
|
-
await t.ctx.stack.undelete(record.id);
|
|
175
|
-
expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(5);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { WIRE_PROTOCOL_VERSION } from '@haverstack/wire-types';
|
|
3
|
-
import { buildTestApp, req, TEST_ENTITY_ID, type TestApp } from '../setup.js';
|
|
4
|
-
|
|
5
|
-
describe('GET /.well-known/stack', () => {
|
|
6
|
-
let t: TestApp;
|
|
7
|
-
beforeEach(async () => {
|
|
8
|
-
t = await buildTestApp();
|
|
9
|
-
});
|
|
10
|
-
afterEach(async () => {
|
|
11
|
-
await t.cleanup();
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('returns the discovery document', async () => {
|
|
15
|
-
const { status, data } = await req(t.app, 'GET', '/.well-known/stack');
|
|
16
|
-
expect(status).toBe(200);
|
|
17
|
-
const d = data as Record<string, unknown>;
|
|
18
|
-
expect(d.version).toBe(WIRE_PROTOCOL_VERSION);
|
|
19
|
-
expect(d.entityId).toBe(TEST_ENTITY_ID);
|
|
20
|
-
expect(d.timezone).toBe('UTC');
|
|
21
|
-
expect(d.capabilities).toBeDefined();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('does not require authentication', async () => {
|
|
25
|
-
const { status } = await req(t.app, 'GET', '/.well-known/stack');
|
|
26
|
-
expect(status).toBe(200);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("declares the server's own size ceilings, not the adapter's null", async () => {
|
|
30
|
-
const { data } = await req(t.app, 'GET', '/.well-known/stack');
|
|
31
|
-
const capabilities = (data as { capabilities: { limits: Record<string, unknown> } })
|
|
32
|
-
.capabilities;
|
|
33
|
-
expect(capabilities.limits.attachmentBytes).toBe(50 * 1024 * 1024);
|
|
34
|
-
expect(capabilities.limits.contentBytes).toBe(1 * 1024 * 1024);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('advertises path-reach content filtering — spread from the sqlite adapter', async () => {
|
|
38
|
-
const { data } = await req(t.app, 'GET', '/.well-known/stack');
|
|
39
|
-
const capabilities = (data as { capabilities: { filter: Record<string, unknown> } })
|
|
40
|
-
.capabilities;
|
|
41
|
-
expect(capabilities.filter.content).toBe('path');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('omits timezone entirely when the stack has none configured', async () => {
|
|
45
|
-
const noTzApp = await buildTestApp({ timezone: undefined });
|
|
46
|
-
try {
|
|
47
|
-
const { data } = await req(noTzApp.app, 'GET', '/.well-known/stack');
|
|
48
|
-
expect('timezone' in (data as Record<string, unknown>)).toBe(false);
|
|
49
|
-
} finally {
|
|
50
|
-
await noTzApp.cleanup();
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('advertises the DID challenge-response handshake', async () => {
|
|
55
|
-
const { data } = await req(t.app, 'GET', '/.well-known/stack');
|
|
56
|
-
expect((data as { auth?: { methods: string[] } }).auth).toEqual({
|
|
57
|
-
methods: ['did-challenge'],
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('advertises the change feed', async () => {
|
|
62
|
-
const { data } = await req(t.app, 'GET', '/.well-known/stack');
|
|
63
|
-
expect((data as { changes?: unknown }).changes).toEqual({
|
|
64
|
-
transports: ['sse'],
|
|
65
|
-
// GET /changes mints and honors resume cursors.
|
|
66
|
-
resume: true,
|
|
67
|
-
// GET /changes honors ?include=record unconditionally.
|
|
68
|
-
records: true,
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
});
|
package/tests/setup.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { tmpdir } from 'node:os';
|
|
2
|
-
import { join, dirname } from 'node:path';
|
|
3
|
-
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import { rm } from 'node:fs/promises';
|
|
5
|
-
import { mkdirSync } from 'node:fs';
|
|
6
|
-
import { LocalAdapter, NativeTokenStore, defaultTokenStorePath } from '@haverstack/adapter-local';
|
|
7
|
-
import { Stack } from '@haverstack/core';
|
|
8
|
-
import { authOriginFromUrl } from '@haverstack/core/wire';
|
|
9
|
-
import pino from 'pino';
|
|
10
|
-
import { createApp } from '../src/app.js';
|
|
11
|
-
import type { Config } from '../src/config.js';
|
|
12
|
-
import type { StackContext } from '../src/stack.js';
|
|
13
|
-
import { AuthNonceStore, defaultNonceStorePath } from '../src/lib/nonceStore.js';
|
|
14
|
-
import { QueryWorkerPool } from '../src/lib/queryWorker/pool.js';
|
|
15
|
-
import { ChangeStreamRegistry } from '../src/lib/changeStreams.js';
|
|
16
|
-
import type { Hono } from 'hono';
|
|
17
|
-
import type { AppEnv } from '../src/app.js';
|
|
18
|
-
|
|
19
|
-
export const TEST_ENTITY_ID = 'did:key:test-entity-id-00000001';
|
|
20
|
-
export const TEST_TOKEN = 'test-bearer-token';
|
|
21
|
-
export const OTHER_ENTITY_ID = 'did:key:other-entity-id-00000002';
|
|
22
|
-
// Matches @haverstack/conformance-fixtures' AUTH_FIXTURE_ORIGIN — the auth
|
|
23
|
-
// handshake fixtures carry real signatures over this exact origin, so the
|
|
24
|
-
// test harness must present itself as it.
|
|
25
|
-
export const TEST_BASE_URL = 'https://stack.example.com';
|
|
26
|
-
|
|
27
|
-
export const logger = pino({ level: 'silent' });
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Each test gets its own isolated temp directory so the SQLiteAdapter's
|
|
31
|
-
* sibling `attachments/` folder never collides between parallel test runs.
|
|
32
|
-
*/
|
|
33
|
-
export function tempDbPath(): string {
|
|
34
|
-
const dir = join(tmpdir(), `haverstack-test-${randomBytes(8).toString('hex')}`);
|
|
35
|
-
mkdirSync(dir, { recursive: true });
|
|
36
|
-
return join(dir, 'stack.db');
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type TestContextOpts = {
|
|
40
|
-
/**
|
|
41
|
-
* IANA timezone string, or `undefined` to opt out of one. Note: a plain
|
|
42
|
-
* default parameter can't tell "omitted" from "explicitly undefined"
|
|
43
|
-
* (both trigger the default), so this must be an options bag — pass
|
|
44
|
-
* `{ timezone: undefined }` deliberately, not the bare value.
|
|
45
|
-
*/
|
|
46
|
-
timezone?: string;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
export async function createTestContext(
|
|
50
|
-
dbPath: string,
|
|
51
|
-
opts: TestContextOpts = { timezone: 'UTC' },
|
|
52
|
-
): Promise<StackContext> {
|
|
53
|
-
const adapter = await LocalAdapter.initialize({
|
|
54
|
-
path: dbPath,
|
|
55
|
-
entityId: TEST_ENTITY_ID,
|
|
56
|
-
...(opts.timezone !== undefined && { timezone: opts.timezone }),
|
|
57
|
-
});
|
|
58
|
-
const stack = await Stack.create(adapter);
|
|
59
|
-
const tokens = await NativeTokenStore.open({ path: defaultTokenStorePath(dbPath) });
|
|
60
|
-
const nonces = AuthNonceStore.open(defaultNonceStorePath(dbPath));
|
|
61
|
-
const queryWorker = new QueryWorkerPool({
|
|
62
|
-
init: { dbPath },
|
|
63
|
-
poolSize: 1,
|
|
64
|
-
queueLimit: 64,
|
|
65
|
-
logger,
|
|
66
|
-
});
|
|
67
|
-
return { adapter, stack, tokens, nonces, queryWorker, changeStreams: new ChangeStreamRegistry() };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function testConfig(dbPath: string, opts: TestContextOpts = { timezone: 'UTC' }): Config {
|
|
71
|
-
return {
|
|
72
|
-
port: 3000,
|
|
73
|
-
dbPath,
|
|
74
|
-
entityId: TEST_ENTITY_ID,
|
|
75
|
-
ownerName: null,
|
|
76
|
-
ownerHandle: null,
|
|
77
|
-
timezone: opts.timezone,
|
|
78
|
-
ownerToken: TEST_TOKEN,
|
|
79
|
-
corsOrigins: '*',
|
|
80
|
-
baseUrl: TEST_BASE_URL,
|
|
81
|
-
authOrigin: authOriginFromUrl(TEST_BASE_URL),
|
|
82
|
-
maxAttachmentBytes: 50 * 1024 * 1024,
|
|
83
|
-
maxContentBytes: 1 * 1024 * 1024,
|
|
84
|
-
queryTimeoutMs: 10_000,
|
|
85
|
-
queryWorkerPoolSize: 1,
|
|
86
|
-
queryQueueLimit: 64,
|
|
87
|
-
seedCommonsTypes: false,
|
|
88
|
-
shutdownTimeoutMs: 10_000,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export type TestApp = {
|
|
93
|
-
app: Hono<AppEnv>;
|
|
94
|
-
ctx: StackContext;
|
|
95
|
-
dbPath: string;
|
|
96
|
-
cleanup: () => Promise<void>;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
export async function buildTestApp(opts: TestContextOpts = { timezone: 'UTC' }): Promise<TestApp> {
|
|
100
|
-
const dbPath = tempDbPath();
|
|
101
|
-
const ctx = await createTestContext(dbPath, opts);
|
|
102
|
-
const config = testConfig(dbPath, opts);
|
|
103
|
-
const app = createApp(ctx, config, logger);
|
|
104
|
-
|
|
105
|
-
const cleanup = async () => {
|
|
106
|
-
await ctx.queryWorker.close();
|
|
107
|
-
await ctx.stack.close();
|
|
108
|
-
await ctx.tokens.close();
|
|
109
|
-
ctx.nonces.close();
|
|
110
|
-
// Remove the whole temp directory (includes the .db file and attachments/).
|
|
111
|
-
await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {});
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
return { app, ctx, dbPath, cleanup };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export type ReqOpts = {
|
|
118
|
-
/** Adds Authorization: Bearer <token> header. */
|
|
119
|
-
token?: string;
|
|
120
|
-
/** JSON-serialised as the request body with Content-Type: application/json. */
|
|
121
|
-
body?: unknown;
|
|
122
|
-
/** Additional headers merged after auth/content-type. */
|
|
123
|
-
headers?: Record<string, string>;
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Fire a request at the Hono test app and return status + parsed JSON body.
|
|
128
|
-
*/
|
|
129
|
-
export async function req(
|
|
130
|
-
app: Hono<AppEnv>,
|
|
131
|
-
method: string,
|
|
132
|
-
path: string,
|
|
133
|
-
opts: ReqOpts = {},
|
|
134
|
-
): Promise<{ status: number; data: unknown }> {
|
|
135
|
-
const headers: Record<string, string> = {};
|
|
136
|
-
if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
|
|
137
|
-
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
138
|
-
Object.assign(headers, opts.headers);
|
|
139
|
-
|
|
140
|
-
const res = await app.request(path, {
|
|
141
|
-
method,
|
|
142
|
-
headers,
|
|
143
|
-
...(opts.body !== undefined && { body: JSON.stringify(opts.body) }),
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const text = await res.text();
|
|
147
|
-
let data: unknown;
|
|
148
|
-
try {
|
|
149
|
-
data = JSON.parse(text);
|
|
150
|
-
} catch {
|
|
151
|
-
data = text;
|
|
152
|
-
}
|
|
153
|
-
return { status: res.status, data };
|
|
154
|
-
}
|
package/tests/shutdown.test.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import type { Logger } from 'pino';
|
|
3
|
-
import type { StackContext } from '../src/stack.js';
|
|
4
|
-
import { createShutdownHandler, type ShutdownServer } from '../src/shutdown.js';
|
|
5
|
-
import { ChangeStreamRegistry } from '../src/lib/changeStreams.js';
|
|
6
|
-
|
|
7
|
-
function fakeLogger(): Logger {
|
|
8
|
-
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function fakeCtx(): StackContext {
|
|
12
|
-
return {
|
|
13
|
-
adapter: {} as StackContext['adapter'],
|
|
14
|
-
stack: {
|
|
15
|
-
flush: vi.fn().mockResolvedValue(undefined),
|
|
16
|
-
close: vi.fn().mockResolvedValue(undefined),
|
|
17
|
-
} as unknown as StackContext['stack'],
|
|
18
|
-
tokens: { close: vi.fn().mockResolvedValue(undefined) } as unknown as StackContext['tokens'],
|
|
19
|
-
nonces: { close: vi.fn() } as unknown as StackContext['nonces'],
|
|
20
|
-
queryWorker: {
|
|
21
|
-
close: vi.fn().mockResolvedValue(undefined),
|
|
22
|
-
} as unknown as StackContext['queryWorker'],
|
|
23
|
-
changeStreams: new ChangeStreamRegistry(),
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe('createShutdownHandler', () => {
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
vi.useFakeTimers();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
vi.useRealTimers();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('runs the full cleanup sequence once close() finishes on its own', async () => {
|
|
37
|
-
const closeAllConnections = vi.fn();
|
|
38
|
-
const server: ShutdownServer = { close: (cb) => cb(), closeAllConnections };
|
|
39
|
-
const ctx = fakeCtx();
|
|
40
|
-
|
|
41
|
-
await createShutdownHandler(server, ctx, fakeLogger(), 10_000)('SIGTERM');
|
|
42
|
-
|
|
43
|
-
expect(closeAllConnections).not.toHaveBeenCalled();
|
|
44
|
-
expect(ctx.queryWorker.close).toHaveBeenCalled();
|
|
45
|
-
expect(ctx.stack.flush).toHaveBeenCalled();
|
|
46
|
-
expect(ctx.stack.close).toHaveBeenCalled();
|
|
47
|
-
expect(ctx.tokens.close).toHaveBeenCalled();
|
|
48
|
-
expect(ctx.nonces.close).toHaveBeenCalled();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('does not force connections closed if close() finishes just under the deadline', async () => {
|
|
52
|
-
const closeAllConnections = vi.fn();
|
|
53
|
-
let closeCallback: (() => void) | undefined;
|
|
54
|
-
const server: ShutdownServer = {
|
|
55
|
-
close: (cb) => {
|
|
56
|
-
closeCallback = cb;
|
|
57
|
-
},
|
|
58
|
-
closeAllConnections,
|
|
59
|
-
};
|
|
60
|
-
const ctx = fakeCtx();
|
|
61
|
-
|
|
62
|
-
const done = createShutdownHandler(server, ctx, fakeLogger(), 10_000)('SIGTERM');
|
|
63
|
-
closeCallback?.();
|
|
64
|
-
await done;
|
|
65
|
-
|
|
66
|
-
expect(closeAllConnections).not.toHaveBeenCalled();
|
|
67
|
-
expect(ctx.queryWorker.close).toHaveBeenCalled();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('forces connections closed once the deadline elapses, then still finishes cleanup', async () => {
|
|
71
|
-
// Simulates a real server: destroying the sockets is what lets the
|
|
72
|
-
// pending close() callback finally fire.
|
|
73
|
-
let closeCallback: (() => void) | undefined;
|
|
74
|
-
const closeAllConnections = vi.fn(() => closeCallback?.());
|
|
75
|
-
const server: ShutdownServer = {
|
|
76
|
-
close: (cb) => {
|
|
77
|
-
closeCallback = cb;
|
|
78
|
-
},
|
|
79
|
-
closeAllConnections,
|
|
80
|
-
};
|
|
81
|
-
const ctx = fakeCtx();
|
|
82
|
-
const logger = fakeLogger();
|
|
83
|
-
|
|
84
|
-
const done = createShutdownHandler(server, ctx, logger, 10_000)('SIGTERM');
|
|
85
|
-
await vi.advanceTimersByTimeAsync(10_000);
|
|
86
|
-
await done;
|
|
87
|
-
|
|
88
|
-
expect(closeAllConnections).toHaveBeenCalledTimes(1);
|
|
89
|
-
expect(logger.warn).toHaveBeenCalled();
|
|
90
|
-
expect(ctx.queryWorker.close).toHaveBeenCalled();
|
|
91
|
-
expect(ctx.stack.flush).toHaveBeenCalled();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('closes every open change-feed stream before waiting on server.close()', async () => {
|
|
95
|
-
const server: ShutdownServer = { close: (cb) => cb() };
|
|
96
|
-
const ctx = fakeCtx();
|
|
97
|
-
const closeStream = vi.fn();
|
|
98
|
-
ctx.changeStreams.add(closeStream);
|
|
99
|
-
|
|
100
|
-
await createShutdownHandler(server, ctx, fakeLogger(), 10_000)('SIGTERM');
|
|
101
|
-
|
|
102
|
-
expect(closeStream).toHaveBeenCalled();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('tolerates a server with no closeAllConnections (e.g. Http2Server typings)', async () => {
|
|
106
|
-
let closeCallback: (() => void) | undefined;
|
|
107
|
-
const server: ShutdownServer = {
|
|
108
|
-
close: (cb) => {
|
|
109
|
-
closeCallback = cb;
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
const ctx = fakeCtx();
|
|
113
|
-
|
|
114
|
-
const done = createShutdownHandler(server, ctx, fakeLogger(), 10_000)('SIGTERM');
|
|
115
|
-
await vi.advanceTimersByTimeAsync(10_000);
|
|
116
|
-
closeCallback?.();
|
|
117
|
-
await done;
|
|
118
|
-
|
|
119
|
-
expect(ctx.queryWorker.close).toHaveBeenCalled();
|
|
120
|
-
});
|
|
121
|
-
});
|