@pikku/core 0.12.96 → 0.12.98
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/CHANGELOG.md +50 -0
- package/dist/classification/data-classification.d.ts +10 -0
- package/dist/classification/data-lock.d.ts +80 -0
- package/dist/classification/data-lock.js +146 -0
- package/dist/classification/index.d.ts +1 -0
- package/dist/classification/index.js +1 -0
- package/dist/classification/key-ids.d.ts +2 -0
- package/dist/classification/key-ids.js +2 -0
- package/dist/middleware/index.d.ts +2 -1
- package/dist/middleware/index.js +2 -1
- package/dist/middleware/require-origin.d.ts +23 -0
- package/dist/middleware/require-origin.js +57 -0
- package/dist/middleware/require-unlocked.d.ts +23 -0
- package/dist/middleware/require-unlocked.js +21 -0
- package/dist/services/secret-service.d.ts +5 -0
- package/dist/services/typed-variables-service.d.ts +34 -0
- package/dist/services/typed-variables-service.js +69 -3
- package/dist/wirings/data-lock/data-lock-wiring.d.ts +40 -0
- package/dist/wirings/data-lock/data-lock-wiring.js +77 -0
- package/dist/wirings/data-lock/index.d.ts +9 -0
- package/dist/wirings/data-lock/index.js +8 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +7 -1
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +25 -3
- package/package.json +1 -1
- package/src/classification/data-classification.ts +10 -0
- package/src/classification/index.ts +2 -0
- package/src/classification/key-ids.ts +2 -0
- package/src/middleware/index.ts +1 -1
- package/src/middleware/require-origin.test.ts +115 -0
- package/src/middleware/require-origin.ts +79 -0
- package/src/public-surface.json +5 -2
- package/src/services/secret-service.ts +5 -0
- package/src/services/typed-variables-service.test.ts +93 -0
- package/src/services/typed-variables-service.ts +94 -3
- package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +59 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +37 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DataLock, LockState } from '../../classification/data-lock.js';
|
|
2
|
+
export type DataLockStatus = {
|
|
3
|
+
state: LockState;
|
|
4
|
+
/**
|
|
5
|
+
* Milliseconds before another guess will be looked at, or 0.
|
|
6
|
+
*
|
|
7
|
+
* The unlock screen shows this as a countdown; without it the only way to
|
|
8
|
+
* learn the wait is over is to guess again, and a guess made during a
|
|
9
|
+
* lockout is itself a failure that extends it.
|
|
10
|
+
*/
|
|
11
|
+
retryAfterMs: number;
|
|
12
|
+
};
|
|
13
|
+
export type DataLockWiringOptions = {
|
|
14
|
+
/** Where the lock routes are mounted. Defaults to `/_pikku/data`. */
|
|
15
|
+
prefix?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Which keys first-run initialization mints. Derive it with
|
|
18
|
+
* `keyIdsFromManifest`.
|
|
19
|
+
*
|
|
20
|
+
* It is fixed here rather than sent by the caller because the unlock screen
|
|
21
|
+
* posts a passphrase and nothing else — and because a key the schema names
|
|
22
|
+
* but nobody minted does not fail at startup, it fails at the first write to
|
|
23
|
+
* that one column.
|
|
24
|
+
*/
|
|
25
|
+
keyIds?: string[];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Puts the passphrase gate on HTTP, so unlocking is a page in the app rather
|
|
29
|
+
* than a prompt in whatever happens to have launched the server.
|
|
30
|
+
*
|
|
31
|
+
* That is what lets one story cover both shapes pikku ships in: a desktop
|
|
32
|
+
* build whose window is pointed at the local server, and a headless
|
|
33
|
+
* `pikku serve` somewhere else, unlock the same way and share the unlock
|
|
34
|
+
* screen. A native prompt in the desktop shell would have left the headless
|
|
35
|
+
* case with nothing.
|
|
36
|
+
*
|
|
37
|
+
* The routes are registered here rather than generated because they belong to
|
|
38
|
+
* core: an app has no source file for them to be discovered in.
|
|
39
|
+
*/
|
|
40
|
+
export declare const wireDataLock: (lock: DataLock, { prefix, keyIds }?: DataLockWiringOptions) => void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { pikkuState } from '../../pikku-state.js';
|
|
2
|
+
import { wireHTTP } from '../http/http-runner.js';
|
|
3
|
+
import { httpRouter } from '../http/routers/http-router.js';
|
|
4
|
+
const helperFunctionMeta = (funcId) => ({
|
|
5
|
+
pikkuFuncId: funcId,
|
|
6
|
+
sessionless: true,
|
|
7
|
+
functionType: 'helper',
|
|
8
|
+
inputSchemaName: null,
|
|
9
|
+
outputSchemaName: null,
|
|
10
|
+
});
|
|
11
|
+
const DEFAULT_PREFIX = '/_pikku/data';
|
|
12
|
+
/**
|
|
13
|
+
* Puts the passphrase gate on HTTP, so unlocking is a page in the app rather
|
|
14
|
+
* than a prompt in whatever happens to have launched the server.
|
|
15
|
+
*
|
|
16
|
+
* That is what lets one story cover both shapes pikku ships in: a desktop
|
|
17
|
+
* build whose window is pointed at the local server, and a headless
|
|
18
|
+
* `pikku serve` somewhere else, unlock the same way and share the unlock
|
|
19
|
+
* screen. A native prompt in the desktop shell would have left the headless
|
|
20
|
+
* case with nothing.
|
|
21
|
+
*
|
|
22
|
+
* The routes are registered here rather than generated because they belong to
|
|
23
|
+
* core: an app has no source file for them to be discovered in.
|
|
24
|
+
*/
|
|
25
|
+
export const wireDataLock = (lock, { prefix = DEFAULT_PREFIX, keyIds } = {}) => {
|
|
26
|
+
const status = () => ({
|
|
27
|
+
state: lock.state,
|
|
28
|
+
retryAfterMs: lock.retryAfterMs,
|
|
29
|
+
});
|
|
30
|
+
register(prefix, 'get', '/status', 'pikkuDataLockStatus', async () => status());
|
|
31
|
+
register(prefix, 'post', '/initialize', 'pikkuDataLockInitialize', async (_services, { passphrase }) => {
|
|
32
|
+
await lock.initialize(passphrase, keyIds);
|
|
33
|
+
return status();
|
|
34
|
+
});
|
|
35
|
+
register(prefix, 'post', '/unlock', 'pikkuDataLockUnlock', async (_services, { passphrase }) => {
|
|
36
|
+
await lock.unlock(passphrase);
|
|
37
|
+
return status();
|
|
38
|
+
});
|
|
39
|
+
register(prefix, 'post', '/lock', 'pikkuDataLockLock', async (_services, { passphrase }) => {
|
|
40
|
+
// Locking proves ownership first. An open POST here would be a
|
|
41
|
+
// one-request denial of service: the store shuts and stays shut until
|
|
42
|
+
// someone is around to type the passphrase back in.
|
|
43
|
+
await lock.unlock(passphrase);
|
|
44
|
+
lock.lock();
|
|
45
|
+
return status();
|
|
46
|
+
});
|
|
47
|
+
// A router that has already compiled its table would otherwise answer 404
|
|
48
|
+
// for everything registered after it woke up.
|
|
49
|
+
httpRouter.reset();
|
|
50
|
+
};
|
|
51
|
+
const register = (prefix, method, path, funcId, func) => {
|
|
52
|
+
const route = `${prefix}${path}`;
|
|
53
|
+
const routes = pikkuState(null, 'http', 'routes');
|
|
54
|
+
if (routes.get(method)?.has(route)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const httpMeta = pikkuState(null, 'http', 'meta');
|
|
58
|
+
httpMeta[method][route] = {
|
|
59
|
+
pikkuFuncId: funcId,
|
|
60
|
+
route,
|
|
61
|
+
method,
|
|
62
|
+
// Never a session. The gate cannot sit in front of its own key: a session
|
|
63
|
+
// may itself live in a column this lock is holding shut.
|
|
64
|
+
auth: false,
|
|
65
|
+
requiresSession: false,
|
|
66
|
+
};
|
|
67
|
+
const functionsMeta = pikkuState(null, 'function', 'meta');
|
|
68
|
+
if (!functionsMeta[funcId]) {
|
|
69
|
+
functionsMeta[funcId] = helperFunctionMeta(funcId);
|
|
70
|
+
}
|
|
71
|
+
wireHTTP({
|
|
72
|
+
method,
|
|
73
|
+
route,
|
|
74
|
+
func: { func },
|
|
75
|
+
auth: false,
|
|
76
|
+
});
|
|
77
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP face of {@link DataLock}: the routes an unlock screen talks to.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `@pikku/core/classification` on purpose — that entry point is
|
|
5
|
+
* types and crypto, and a runtime that never serves HTTP should not have to
|
|
6
|
+
* load a router to use it.
|
|
7
|
+
*/
|
|
8
|
+
export { wireDataLock } from './data-lock-wiring.js';
|
|
9
|
+
export type { DataLockStatus, DataLockWiringOptions, } from './data-lock-wiring.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP face of {@link DataLock}: the routes an unlock screen talks to.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `@pikku/core/classification` on purpose — that entry point is
|
|
5
|
+
* types and crypto, and a runtime that never serves HTTP should not have to
|
|
6
|
+
* load a router to use it.
|
|
7
|
+
*/
|
|
8
|
+
export { wireDataLock } from './data-lock-wiring.js';
|
|
@@ -4,6 +4,7 @@ import type { VariablesService } from '../../services/variables-service.js';
|
|
|
4
4
|
import type { AgentRunnerService } from '../../services/agent-runner-service.js';
|
|
5
5
|
import type { HttpPersonasConfig } from '../../services/http-personas.js';
|
|
6
6
|
import type { ResolvedPersona, ScenarioPersonas } from '../../services/personas-service.js';
|
|
7
|
+
import type { PersonaEnvironment } from '../persona/persona-environments.js';
|
|
7
8
|
import type { StepRecord, VirtualUserDisposition } from './virtual-user.types.js';
|
|
8
9
|
import type { VirtualUserRunRecord, VirtualUserRunStore } from './virtual-user-run-store.js';
|
|
9
10
|
import type { VirtualUserScheduleRecord, VirtualUserScheduleStore } from './virtual-user-schedule-store.js';
|
|
@@ -84,10 +85,15 @@ export interface StartVirtualUserRunParams {
|
|
|
84
85
|
/**
|
|
85
86
|
* The app's config, read only for `nodeEnv` — structural because an
|
|
86
87
|
* application's Config is its own interface and need not declare it at all.
|
|
88
|
+
* The fallback signal, used only by a project that configures no environments.
|
|
87
89
|
*/
|
|
88
90
|
config: {
|
|
89
91
|
nodeEnv?: string;
|
|
90
92
|
} | undefined;
|
|
93
|
+
/** `environments` from pikku.config.json, as generated beside the personas. */
|
|
94
|
+
environments?: Readonly<Record<string, PersonaEnvironment>>;
|
|
95
|
+
/** Which of them this process is. Defaults to `PIKKU_ENV`. */
|
|
96
|
+
environment?: string;
|
|
91
97
|
persona: string;
|
|
92
98
|
disposition?: string;
|
|
93
99
|
seed?: number;
|
|
@@ -112,7 +118,7 @@ export interface StartedVirtualUserRun {
|
|
|
112
118
|
* scheduled tick have in common. The dispatch that follows is typed off the
|
|
113
119
|
* app's RPC map, so it stays in the generated wiring.
|
|
114
120
|
*/
|
|
115
|
-
export declare const startVirtualUserRun: ({ store, personas, config, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }: StartVirtualUserRunParams) => Promise<StartedVirtualUserRun>;
|
|
121
|
+
export declare const startVirtualUserRun: ({ store, personas, config, environments, environment, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }: StartVirtualUserRunParams) => Promise<StartedVirtualUserRun>;
|
|
116
122
|
/**
|
|
117
123
|
* One run on the wire.
|
|
118
124
|
*
|
|
@@ -85,6 +85,28 @@ export const requireVirtualUserScheduleStore = (store) => {
|
|
|
85
85
|
}
|
|
86
86
|
return store;
|
|
87
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* Whether this process is running against production, for the disposition rule.
|
|
90
|
+
*
|
|
91
|
+
* The configured environment wins over `NODE_ENV` because they answer different
|
|
92
|
+
* questions. A deployment whose staging is a production *mirror* runs
|
|
93
|
+
* `NODE_ENV=production` there too — keying on it refuses every disposition on
|
|
94
|
+
* the one environment they exist to be used on. `PIKKU_ENV` names which of the
|
|
95
|
+
* configured environments this is, which is the question actually being asked,
|
|
96
|
+
* and it is the same signal `personaEnvironmentRefusal` already checks at
|
|
97
|
+
* sign-in.
|
|
98
|
+
*
|
|
99
|
+
* Unresolved is treated as production: an environment nobody can name is one
|
|
100
|
+
* whose data nobody can vouch for. `NODE_ENV` remains the answer only for a
|
|
101
|
+
* project that configures no environments at all, which has no production
|
|
102
|
+
* environment declared for this to be wrong about.
|
|
103
|
+
*/
|
|
104
|
+
const isProductionRun = (config, environments, environment) => {
|
|
105
|
+
if (!environments || Object.keys(environments).length === 0) {
|
|
106
|
+
return config?.nodeEnv === 'production';
|
|
107
|
+
}
|
|
108
|
+
return environment ? Boolean(environments[environment]?.production) : true;
|
|
109
|
+
};
|
|
88
110
|
/**
|
|
89
111
|
* Resolves a request against the declaration and records the run.
|
|
90
112
|
*
|
|
@@ -92,7 +114,7 @@ export const requireVirtualUserScheduleStore = (store) => {
|
|
|
92
114
|
* scheduled tick have in common. The dispatch that follows is typed off the
|
|
93
115
|
* app's RPC map, so it stays in the generated wiring.
|
|
94
116
|
*/
|
|
95
|
-
export const startVirtualUserRun = async ({ store, personas, config, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }) => {
|
|
117
|
+
export const startVirtualUserRun = async ({ store, personas, config, environments, environment = process.env.PIKKU_ENV, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }) => {
|
|
96
118
|
const runStore = requireVirtualUserRunStore(store);
|
|
97
119
|
const persona = runnablePersona(personas, personaId);
|
|
98
120
|
const disposition = (requested ??
|
|
@@ -101,8 +123,8 @@ export const startVirtualUserRun = async ({ store, personas, config, persona: pe
|
|
|
101
123
|
// Every disposition other than this one exists to find out what the product
|
|
102
124
|
// does wrong, which is not a thing to do to real customers' data. Checked
|
|
103
125
|
// against the effective disposition, so an override cannot smuggle one in.
|
|
104
|
-
if (
|
|
105
|
-
|
|
126
|
+
if (disposition !== PRODUCTION_DISPOSITION &&
|
|
127
|
+
isProductionRun(config, environments, environment)) {
|
|
106
128
|
throw new Error(`Only the '${PRODUCTION_DISPOSITION}' disposition may run against production; "${personaId}" is ${disposition}`);
|
|
107
129
|
}
|
|
108
130
|
// Seeded here rather than inside the engine so the record carries the seed
|
package/package.json
CHANGED
|
@@ -54,6 +54,16 @@ export interface ColumnClassification {
|
|
|
54
54
|
anonymize_strategy: AnonymizeStrategy
|
|
55
55
|
/** At-rest representation. Absent means `plain`. */
|
|
56
56
|
form?: ColumnForm
|
|
57
|
+
/**
|
|
58
|
+
* Which key protects this column, for a `wrapped` or `sealed` form. Absent
|
|
59
|
+
* means the deployment's default key.
|
|
60
|
+
*
|
|
61
|
+
* It is a purpose, not a tenant: naming one here says "these columns open
|
|
62
|
+
* together and separately from the rest", so the key that opens notes need
|
|
63
|
+
* not open credentials. The id is stored in the value, so a column that
|
|
64
|
+
* changes key is a rewrap rather than a migration.
|
|
65
|
+
*/
|
|
66
|
+
keyId?: string
|
|
57
67
|
description?: string
|
|
58
68
|
}
|
|
59
69
|
|
package/src/middleware/index.ts
CHANGED
|
@@ -3,10 +3,10 @@ export { authCookie } from './auth-cookie.js'
|
|
|
3
3
|
export { authBearer } from './auth-bearer.js'
|
|
4
4
|
export { pikkuRemoteAuthMiddleware } from './remote-auth.js'
|
|
5
5
|
export { cors } from './cors.js'
|
|
6
|
+
export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js'
|
|
6
7
|
export { telemetryOuter, telemetryInner } from './telemetry.js'
|
|
7
8
|
export {
|
|
8
9
|
addTagMiddleware,
|
|
9
|
-
addTagMiddleware as addMiddleware,
|
|
10
10
|
addGlobalMiddleware,
|
|
11
11
|
runMiddleware,
|
|
12
12
|
} from '../middleware-runner.js'
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js'
|
|
4
|
+
import { InvalidOriginError } from '../errors/errors.js'
|
|
5
|
+
import { resetPikkuState } from '../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
resetPikkuState()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const headers = (values: Record<string, string | undefined>) => ({
|
|
12
|
+
method: () => 'post',
|
|
13
|
+
header: (name: string) => values[name],
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const run = async (
|
|
17
|
+
config: Parameters<typeof requireOrigin>[0],
|
|
18
|
+
values: Record<string, string | undefined>
|
|
19
|
+
) => {
|
|
20
|
+
let reached = false
|
|
21
|
+
const middleware = requireOrigin(config)
|
|
22
|
+
await middleware({} as any, { http: { request: headers(values) } } as any, async () => {
|
|
23
|
+
reached = true
|
|
24
|
+
})
|
|
25
|
+
return reached
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('toOrigin', () => {
|
|
29
|
+
test('keeps scheme, host and port and drops the rest', () => {
|
|
30
|
+
assert.equal(toOrigin('https://app.com:8443/a/b?c=1'), 'https://app.com:8443')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('rejects the sandboxed-iframe "null" origin and unparseable values', () => {
|
|
34
|
+
assert.equal(toOrigin('null'), null)
|
|
35
|
+
assert.equal(toOrigin(''), null)
|
|
36
|
+
assert.equal(toOrigin(undefined), null)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('isAllowedOrigin', () => {
|
|
41
|
+
test('matches the request host exactly', () => {
|
|
42
|
+
assert.equal(isAllowedOrigin('https://app.com', 'https://app.com', []), true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('does not suffix-match a lookalike domain', () => {
|
|
46
|
+
assert.equal(isAllowedOrigin('https://evil-app.com', null, ['https://app.com']), false)
|
|
47
|
+
assert.equal(isAllowedOrigin('https://app.com.evil.net', null, ['https://app.com']), false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('normalises a configured origin before comparing', () => {
|
|
51
|
+
assert.equal(isAllowedOrigin('https://app.com', null, ['https://app.com/path']), true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('rejects a missing origin', () => {
|
|
55
|
+
assert.equal(isAllowedOrigin(null, 'https://app.com', ['https://app.com']), false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('requireOrigin', () => {
|
|
60
|
+
test('allows a beacon from the request own host', async () => {
|
|
61
|
+
assert.equal(
|
|
62
|
+
await run({}, { origin: 'https://app.com', host: 'app.com' }),
|
|
63
|
+
true
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('falls back to referer when only it is sent', async () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
await run({}, { referer: 'https://app.com/pricing', host: 'app.com' }),
|
|
70
|
+
true
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('honours x-forwarded-proto when deriving the host origin', async () => {
|
|
75
|
+
assert.equal(
|
|
76
|
+
await run(
|
|
77
|
+
{},
|
|
78
|
+
{ origin: 'http://app.com', host: 'app.com', 'x-forwarded-proto': 'http' }
|
|
79
|
+
),
|
|
80
|
+
true
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('rejects another site with a 403', async () => {
|
|
85
|
+
await assert.rejects(
|
|
86
|
+
() => run({}, { origin: 'https://evil.com', host: 'app.com' }),
|
|
87
|
+
InvalidOriginError
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('rejects a non-browser caller that sends no origin', async () => {
|
|
92
|
+
await assert.rejects(
|
|
93
|
+
() => run({}, { host: 'app.com' }),
|
|
94
|
+
InvalidOriginError
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('resolves configured origins from services when given a function', async () => {
|
|
99
|
+
assert.equal(
|
|
100
|
+
await run(
|
|
101
|
+
{ origins: async () => ['https://other.com'] },
|
|
102
|
+
{ origin: 'https://other.com', host: 'app.com' }
|
|
103
|
+
),
|
|
104
|
+
true
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('passes through when there is no http wire at all', async () => {
|
|
109
|
+
let reached = false
|
|
110
|
+
await requireOrigin({})({} as any, {} as any, async () => {
|
|
111
|
+
reached = true
|
|
112
|
+
})
|
|
113
|
+
assert.equal(reached, true)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CoreSingletonServices } from '../types/core.types.js'
|
|
2
|
+
import { InvalidOriginError } from '../errors/errors.js'
|
|
3
|
+
import {
|
|
4
|
+
pikkuMiddleware,
|
|
5
|
+
pikkuMiddlewareFactory,
|
|
6
|
+
} from './middleware-factories.js'
|
|
7
|
+
|
|
8
|
+
/** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
|
|
9
|
+
export const toOrigin = (value: string | null | undefined): string | null => {
|
|
10
|
+
if (!value) return null
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value)
|
|
13
|
+
return url.protocol && url.host ? url.origin : null
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a request origin may post to an origin-locked route.
|
|
21
|
+
*
|
|
22
|
+
* The comparison is exact on the parsed origin, never a suffix match:
|
|
23
|
+
* `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
|
|
24
|
+
*/
|
|
25
|
+
export const isAllowedOrigin = (
|
|
26
|
+
requestOrigin: string | null,
|
|
27
|
+
hostOrigin: string | null,
|
|
28
|
+
configuredOrigins: string[]
|
|
29
|
+
): boolean => {
|
|
30
|
+
if (!requestOrigin) return false
|
|
31
|
+
if (hostOrigin && requestOrigin === hostOrigin) return true
|
|
32
|
+
return configuredOrigins.some((allowed) => toOrigin(allowed) === requestOrigin)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
|
|
37
|
+
*
|
|
38
|
+
* This is not what `cors()` does. CORS sets response headers and is enforced by the
|
|
39
|
+
* browser, so a non-browser client ignores them and the request still runs; this rejects
|
|
40
|
+
* before the function body. It stops another site's page from posting to an unauthed
|
|
41
|
+
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
42
|
+
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
43
|
+
*/
|
|
44
|
+
export const requireOrigin = pikkuMiddlewareFactory<{
|
|
45
|
+
/** Extra allowed origins beyond the request's own host, or a resolver for them. */
|
|
46
|
+
origins?:
|
|
47
|
+
| string[]
|
|
48
|
+
| ((services: CoreSingletonServices) => string[] | Promise<string[]>)
|
|
49
|
+
}>(({ origins = [] } = {}) =>
|
|
50
|
+
pikkuMiddleware({
|
|
51
|
+
name: 'requireOrigin',
|
|
52
|
+
description: 'Rejects requests that did not come from this app.',
|
|
53
|
+
func: async (services, { http }, next) => {
|
|
54
|
+
const request = http?.request
|
|
55
|
+
if (!request) return next()
|
|
56
|
+
|
|
57
|
+
const requestOrigin =
|
|
58
|
+
toOrigin(request.header('origin')) ??
|
|
59
|
+
toOrigin(request.header('referer'))
|
|
60
|
+
|
|
61
|
+
const host = request.header('host')
|
|
62
|
+
const proto = request.header('x-forwarded-proto') ?? 'https'
|
|
63
|
+
const hostOrigin = host ? toOrigin(`${proto}://${host}`) : null
|
|
64
|
+
|
|
65
|
+
const configured =
|
|
66
|
+
typeof origins === 'function'
|
|
67
|
+
? await origins(services as CoreSingletonServices)
|
|
68
|
+
: origins
|
|
69
|
+
|
|
70
|
+
if (!isAllowedOrigin(requestOrigin, hostOrigin, configured)) {
|
|
71
|
+
throw new InvalidOriginError(
|
|
72
|
+
`Rejected origin ${requestOrigin ?? '(none)'}`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return next()
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
)
|
package/src/public-surface.json
CHANGED
|
@@ -4,21 +4,23 @@
|
|
|
4
4
|
"./middleware": [
|
|
5
5
|
"addGlobalMiddleware",
|
|
6
6
|
"addGlobalPermission",
|
|
7
|
-
"addMiddleware",
|
|
8
7
|
"addTagMiddleware",
|
|
9
8
|
"authAPIKey",
|
|
10
9
|
"authBearer",
|
|
11
10
|
"authCookie",
|
|
12
11
|
"cors",
|
|
12
|
+
"isAllowedOrigin",
|
|
13
13
|
"pikkuAgentMiddleware",
|
|
14
14
|
"pikkuChannelMiddleware",
|
|
15
15
|
"pikkuChannelMiddlewareFactory",
|
|
16
16
|
"pikkuMiddleware",
|
|
17
17
|
"pikkuMiddlewareFactory",
|
|
18
18
|
"pikkuRemoteAuthMiddleware",
|
|
19
|
+
"requireOrigin",
|
|
19
20
|
"runMiddleware",
|
|
20
21
|
"telemetryInner",
|
|
21
|
-
"telemetryOuter"
|
|
22
|
+
"telemetryOuter",
|
|
23
|
+
"toOrigin"
|
|
22
24
|
],
|
|
23
25
|
"./function": [
|
|
24
26
|
"AbandonedError",
|
|
@@ -451,6 +453,7 @@
|
|
|
451
453
|
"setSingletonServices"
|
|
452
454
|
],
|
|
453
455
|
"./classification": [
|
|
456
|
+
"DEFAULT_KEY_ID",
|
|
454
457
|
"REDACTED",
|
|
455
458
|
"SecretCoercionError",
|
|
456
459
|
"SecretValue",
|
|
@@ -4,6 +4,11 @@ import type { SecretValue } from '../classification/secret-value.js'
|
|
|
4
4
|
export type SecretValues<T> = { [K in keyof T]: SecretValue<T[K]> }
|
|
5
5
|
|
|
6
6
|
export interface SecretService {
|
|
7
|
+
/**
|
|
8
|
+
* Throws if the secret is not found, unless `defineSecret` declared it
|
|
9
|
+
* `optional` — then absence resolves `undefined`. Unwrap the result with
|
|
10
|
+
* `.reveal()`.
|
|
11
|
+
*/
|
|
7
12
|
getSecret<T = string>(key: string): Promise<SecretValue<T>>
|
|
8
13
|
/** Answers for any key, including a disallowed one — it must not throw. */
|
|
9
14
|
hasSecret(key: string): Promise<boolean>
|
|
@@ -2,6 +2,7 @@ import { describe, test } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
import { TypedVariablesService } from './typed-variables-service.js'
|
|
4
4
|
import { LocalVariablesService } from './local-variables.js'
|
|
5
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
5
6
|
|
|
6
7
|
describe('TypedVariablesService', () => {
|
|
7
8
|
const createService = (vars: Record<string, string | undefined> = {}) => {
|
|
@@ -71,3 +72,95 @@ describe('TypedVariablesService', () => {
|
|
|
71
72
|
assert.strictEqual(missing.length, 0)
|
|
72
73
|
})
|
|
73
74
|
})
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Stands in for `z.enum([...]).default(...)`: a schema that answers `undefined`
|
|
78
|
+
* with a value rather than an issue. Core declares no schema library of its
|
|
79
|
+
* own, so the contract under test is Standard Schema's, not Zod's.
|
|
80
|
+
*/
|
|
81
|
+
const withDefault = <T>(value: T): StandardSchemaV1<unknown, T> => ({
|
|
82
|
+
'~standard': {
|
|
83
|
+
version: 1,
|
|
84
|
+
vendor: 'test',
|
|
85
|
+
validate: (input: unknown) =>
|
|
86
|
+
input === undefined ? { value } : { value: input as T },
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const noDefault: StandardSchemaV1<unknown, string> = {
|
|
91
|
+
'~standard': {
|
|
92
|
+
version: 1,
|
|
93
|
+
vendor: 'test',
|
|
94
|
+
validate: (input: unknown) =>
|
|
95
|
+
typeof input === 'string'
|
|
96
|
+
? { value: input }
|
|
97
|
+
: { issues: [{ message: 'expected a string' }] },
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('TypedVariablesService schema defaults', () => {
|
|
102
|
+
const createService = (vars: Record<string, string | undefined> = {}) =>
|
|
103
|
+
new TypedVariablesService(new LocalVariablesService(vars), {
|
|
104
|
+
GITHUB_BASE_URL: {
|
|
105
|
+
name: 'GITHUB_BASE_URL',
|
|
106
|
+
displayName: 'GitHub Base URL',
|
|
107
|
+
schema: withDefault('https://api.github.com'),
|
|
108
|
+
},
|
|
109
|
+
API_KEY: {
|
|
110
|
+
name: 'API_KEY',
|
|
111
|
+
displayName: 'API Key',
|
|
112
|
+
schema: noDefault,
|
|
113
|
+
},
|
|
114
|
+
// The form code generation emits, deferred past the import cycle.
|
|
115
|
+
REGION: {
|
|
116
|
+
name: 'REGION',
|
|
117
|
+
displayName: 'Region',
|
|
118
|
+
schema: () => withDefault('eu-west-1'),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('resolves a declared default when the host sets nothing', async () => {
|
|
123
|
+
const service = createService()
|
|
124
|
+
assert.strictEqual(
|
|
125
|
+
await service.get('GITHUB_BASE_URL'),
|
|
126
|
+
'https://api.github.com'
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('prefers the host value over the default', async () => {
|
|
131
|
+
const service = createService({ GITHUB_BASE_URL: 'https://ghe.internal' })
|
|
132
|
+
assert.strictEqual(
|
|
133
|
+
await service.get('GITHUB_BASE_URL'),
|
|
134
|
+
'https://ghe.internal'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('stays undefined when the schema carries no default', async () => {
|
|
139
|
+
const service = createService()
|
|
140
|
+
assert.strictEqual(await service.get('API_KEY'), undefined)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('resolves a default behind a thunk', async () => {
|
|
144
|
+
const service = createService()
|
|
145
|
+
assert.strictEqual(await service.get('REGION'), 'eu-west-1')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('a defaulted variable is not missing', async () => {
|
|
149
|
+
const service = createService()
|
|
150
|
+
const missing = await service.getMissing()
|
|
151
|
+
assert.deepStrictEqual(
|
|
152
|
+
missing.map((v) => v.variableId),
|
|
153
|
+
['API_KEY']
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('status separates having a default from being configured', async () => {
|
|
158
|
+
const service = createService()
|
|
159
|
+
const status = await service.getAllStatus()
|
|
160
|
+
const github = status.find((s) => s.variableId === 'GITHUB_BASE_URL')!
|
|
161
|
+
assert.strictEqual(github.isConfigured, false)
|
|
162
|
+
assert.strictEqual(github.hasDefault, true)
|
|
163
|
+
const apiKey = status.find((s) => s.variableId === 'API_KEY')!
|
|
164
|
+
assert.strictEqual(apiKey.hasDefault, false)
|
|
165
|
+
})
|
|
166
|
+
})
|