@owlmeans/api-config 0.1.18-rc.12 → 0.1.18-rc.13
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 +21 -4
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/api-config/SKILL.md +25 -10
- package/build/consts.d.ts +0 -2
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +0 -8
- package/build/consts.js.map +1 -1
- package/build/defaults.d.ts +2 -0
- package/build/defaults.d.ts.map +1 -0
- package/build/defaults.js +33 -0
- package/build/defaults.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/plugins.d.ts +31 -0
- package/build/plugins.d.ts.map +1 -0
- package/build/plugins.js +121 -0
- package/build/plugins.js.map +1 -0
- package/build/types.d.ts +2 -1
- package/build/types.d.ts.map +1 -1
- package/package.json +6 -4
- package/src/consts.ts +0 -10
- package/src/defaults.ts +37 -0
- package/src/index.ts +2 -0
- package/src/plugins.ts +161 -0
- package/src/types.ts +2 -4
- package/tests/plugins.spec.ts +85 -0
- package/tsconfig.json +1 -1
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@ Shared entrypoint for advertising safe config values from server to client via a
|
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
- Exposes a `GET /assets/config.json` entrypoint that returns
|
|
7
|
+
- Exposes a `GET /assets/config.json` entrypoint that returns package-allowlisted config fields
|
|
8
8
|
- `ApiConfig` — the advertised config type (subset of `CommonConfig`)
|
|
9
9
|
- `API_CONFIG` — entrypoint alias for the config endpoint
|
|
10
|
-
- `
|
|
10
|
+
- `apiConfigPlugin` / `every` — import-time allowlist helpers for package-owned client config
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -28,12 +28,29 @@ import type { ApiConfig } from '@owlmeans/api-config'
|
|
|
28
28
|
|
|
29
29
|
### `ApiConfig`
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Partial public config assembled only from imported packages' allowlist plugins. Unregistered
|
|
32
|
+
server values — including databases, queues and secrets — are never returned.
|
|
32
33
|
|
|
33
34
|
### `API_CONFIG`
|
|
34
35
|
|
|
35
36
|
Entrypoint alias `'api-config:advertise'` used to register/call the config endpoint.
|
|
36
37
|
|
|
38
|
+
### `apiConfigPlugin`
|
|
39
|
+
|
|
40
|
+
Call this once at module scope in the package that owns a client-facing configuration field:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { apiConfigPlugin, every } from '@owlmeans/api-config'
|
|
44
|
+
|
|
45
|
+
apiConfigPlugin({
|
|
46
|
+
allow: { integrations: every(true) },
|
|
47
|
+
deny: { integrations: every({ token: true }) },
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `deny` selector removes nested values after the allowlist selection. Prefer a precise nested
|
|
52
|
+
allowlist to `true`; `true` is appropriate only for data that is public at every depth.
|
|
53
|
+
|
|
37
54
|
### `entrypoints`
|
|
38
55
|
|
|
39
56
|
Array of route definitions for the config advertisement endpoint.
|
|
@@ -51,7 +68,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
51
68
|
your project's skill store (`.agents/skills/`):
|
|
52
69
|
|
|
53
70
|
```sh
|
|
54
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
71
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.13
|
|
55
72
|
```
|
|
56
73
|
|
|
57
74
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/api-config",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.13",
|
|
5
|
+
"generatedAt": "2026-09-10T18:54:42.506Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-config
|
|
3
|
-
description: How to use @owlmeans/api-config — the shared entrypoint declaration and
|
|
3
|
+
description: How to use @owlmeans/api-config — the shared entrypoint declaration and import-time allowlist plugins behind the runtime config document a backend advertises and a frontend merges into its own config at boot. Auto-invoked when importing api-config, or when wiring runtime API config between a server and a client.
|
|
4
4
|
user-invocable: false
|
|
5
5
|
---
|
|
6
6
|
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
@@ -8,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/api-config
|
|
9
9
|
|
|
10
10
|
**Layer:** Core
|
|
11
|
-
**Install:** `"@owlmeans/api-config": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/api-config": "^0.1.18-rc.13"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
The contract package of a three-package flow: this one declares the endpoint and what may cross it,
|
|
14
14
|
`@owlmeans/api-config-server` answers it, `@owlmeans/api-config-client` fetches it and merges the
|
|
@@ -20,9 +20,11 @@ answer into the client config. Nothing here runs — it is the shared declaratio
|
|
|
20
20
|
|--------|-------------|
|
|
21
21
|
| `entrypoints` | The single declaration — alias `API_CONFIG`, route `/assets/config.json`, `sticky: true` so a router attaches it unconditionally |
|
|
22
22
|
| `API_CONFIG` | The alias (`api-config:advertise`) both sides elevate |
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
23
|
+
| `apiConfigPlugin(plugin)` | Registers one package's public config selection as its module loads |
|
|
24
|
+
| `every(selection, where?)` | Applies a selection to every list item or object-map value, optionally filtering items |
|
|
25
|
+
| `ApiConfigPlugin` | `{ allow, deny? }` — nested allowlist with an optional nested redaction selector |
|
|
26
|
+
| `advertisedConfig(cfg)` | Applies every imported package contract to build the endpoint document |
|
|
27
|
+
| `ApiConfig` | The partial public document a client merges into its local config |
|
|
26
28
|
|
|
27
29
|
## Usage
|
|
28
30
|
|
|
@@ -40,11 +42,24 @@ side.
|
|
|
40
42
|
|
|
41
43
|
## What may cross
|
|
42
44
|
|
|
43
|
-
The
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
The document is default-deny: a server config value is absent until the package that owns its
|
|
46
|
+
browser consumer registers a precise `allow` selector during import. Do not allow an ancestor with
|
|
47
|
+
`true` unless every descendant is public; name fields instead, and use `deny` when an otherwise
|
|
48
|
+
public collection carries nested credentials.
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { apiConfigPlugin, every } from '@owlmeans/api-config'
|
|
52
|
+
|
|
53
|
+
apiConfigPlugin({
|
|
54
|
+
allow: { oidc: { providers: every(true) } },
|
|
55
|
+
deny: { oidc: { providers: every({ secret: true, apiClientId: true }) } },
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The base contract supplies public routes, branding, login settings, selected debug flags and
|
|
60
|
+
frontend plugins. `@owlmeans/oidc`, `@owlmeans/flow`, `@owlmeans/i18n` and `@owlmeans/payment`
|
|
61
|
+
register their own client settings during import. Databases, queues, SMTP, secrets and every
|
|
62
|
+
unregistered config extension never cross the unauthenticated endpoint.
|
|
48
63
|
|
|
49
64
|
## Depends On
|
|
50
65
|
|
package/build/consts.d.ts
CHANGED
package/build/consts.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,UAAU,yBAAyB,CAAA
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,UAAU,yBAAyB,CAAA"}
|
package/build/consts.js
CHANGED
|
@@ -1,10 +1,2 @@
|
|
|
1
1
|
export const API_CONFIG = 'api-config:advertise';
|
|
2
|
-
export const notAdvertizedConfigKeys = [
|
|
3
|
-
'dbs', 'trusted', 'ready', 'service', 'type',
|
|
4
|
-
'records', 'webService', 'oidc', 'storageBuckets',
|
|
5
|
-
'secrets',
|
|
6
|
-
];
|
|
7
|
-
export const allowedConfigRecords = [
|
|
8
|
-
'plan', 'product', 'l10n'
|
|
9
|
-
];
|
|
10
2
|
//# sourceMappingURL=consts.js.map
|
package/build/consts.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,MAAM,CAAC,MAAM,UAAU,GAAG,sBAAsB,CAAA
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,MAAM,CAAC,MAAM,UAAU,GAAG,sBAAsB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../src/defaults.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AppType } from '@owlmeans/context';
|
|
2
|
+
import { PLUGINS } from '@owlmeans/config';
|
|
3
|
+
import { apiConfigPlugin, every } from './plugins.js';
|
|
4
|
+
const isPublicService = (value) => {
|
|
5
|
+
if (value == null || typeof value !== 'object') {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const service = value;
|
|
9
|
+
// sservice() copies an internal address into host/port. Keep a service only when it has an
|
|
10
|
+
// independently declared public address; otherwise an internal-only peer leaks through host.
|
|
11
|
+
return service.internalHost == null || service.host !== service.internalHost;
|
|
12
|
+
};
|
|
13
|
+
apiConfigPlugin({
|
|
14
|
+
allow: {
|
|
15
|
+
debug: { all: true, i18n: true, supervisor: true },
|
|
16
|
+
brand: true,
|
|
17
|
+
security: {
|
|
18
|
+
unsecure: true,
|
|
19
|
+
auth: { flow: true, enter: true, login: true },
|
|
20
|
+
},
|
|
21
|
+
services: every({
|
|
22
|
+
service: true,
|
|
23
|
+
type: true,
|
|
24
|
+
host: true,
|
|
25
|
+
port: true,
|
|
26
|
+
base: true,
|
|
27
|
+
home: true,
|
|
28
|
+
default: true,
|
|
29
|
+
}, isPublicService),
|
|
30
|
+
[PLUGINS]: every(true, value => value != null && typeof value === 'object' && value.type === AppType.Frontend),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
//# sourceMappingURL=defaults.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"defaults.js","sourceRoot":"","sources":["../src/defaults.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAC3C,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAC1C,OAAO,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;AAErD,MAAM,eAAe,GAAG,CAAC,KAAc,EAAW,EAAE;IAClD,IAAI,KAAK,IAAI,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/C,OAAO,KAAK,CAAA;IACd,CAAC;IACD,MAAM,OAAO,GAAG,KAAmD,CAAA;IAEnE,2FAA2F;IAC3F,6FAA6F;IAC7F,OAAO,OAAO,CAAC,YAAY,IAAI,IAAI,IAAI,OAAO,CAAC,IAAI,KAAK,OAAO,CAAC,YAAY,CAAA;AAC9E,CAAC,CAAA;AAED,eAAe,CAAC;IACd,KAAK,EAAE;QACL,KAAK,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE;QAClD,KAAK,EAAE,IAAI;QACX,QAAQ,EAAE;YACR,QAAQ,EAAE,IAAI;YACd,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;SAC/C;QACD,QAAQ,EAAE,KAAK,CAAC;YACd,OAAO,EAAE,IAAI;YACb,IAAI,EAAE,IAAI;YACV,IAAI,EAAE,IAAI;YACV,IAAI,EAAE,IAAI;YACV,IAAI,EAAE,IAAI;YACV,IAAI,EAAE,IAAI;YACV,OAAO,EAAE,IAAI;SACd,EAAE,eAAe,CAAC;QACnB,CAAC,OAAO,CAAC,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,CAC7B,KAAK,IAAI,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAK,KAA4B,CAAC,IAAI,KAAK,OAAO,CAAC,QAAQ,CACtG;KACF;CACF,CAAC,CAAA"}
|
package/build/index.d.ts
CHANGED
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,eAAe,CAAA"}
|
package/build/index.js
CHANGED
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,eAAe,CAAA"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** A declarative selection of config values that may cross into a browser. */
|
|
2
|
+
export type ConfigSelection = true | ConfigSelectionMap | EveryConfigValue;
|
|
3
|
+
export interface ConfigSelectionMap {
|
|
4
|
+
readonly [key: string]: ConfigSelection | undefined;
|
|
5
|
+
}
|
|
6
|
+
/** Apply a selection to every item in an array or every value in an object map. */
|
|
7
|
+
export interface EveryConfigValue {
|
|
8
|
+
readonly every: ConfigSelection;
|
|
9
|
+
readonly where?: (value: unknown) => boolean;
|
|
10
|
+
}
|
|
11
|
+
/** A package-owned contribution to the public runtime-config document. */
|
|
12
|
+
export interface ApiConfigPlugin {
|
|
13
|
+
/** Fields this package intentionally makes public. */
|
|
14
|
+
readonly allow: ConfigSelection;
|
|
15
|
+
/** Nested fields to remove after selection, even when an ancestor is allowed. */
|
|
16
|
+
readonly deny?: ConfigSelection;
|
|
17
|
+
}
|
|
18
|
+
/** Build a selector for lists and string-keyed maps. */
|
|
19
|
+
export declare const every: (selection: ConfigSelection, where?: (value: unknown) => boolean) => EveryConfigValue;
|
|
20
|
+
/**
|
|
21
|
+
* Register a package's public config contract during module import.
|
|
22
|
+
*
|
|
23
|
+
* The registry is intentionally additive: importing a package can only expose the fields it
|
|
24
|
+
* names, while a package's `deny` selector can still remove sensitive nested values.
|
|
25
|
+
*/
|
|
26
|
+
export declare const apiConfigPlugin: (plugin: ApiConfigPlugin) => void;
|
|
27
|
+
/** Apply one package contract without registering it; useful for focused contract tests. */
|
|
28
|
+
export declare const selectApiConfig: (config: Record<string, unknown>, plugin: ApiConfigPlugin) => Record<string, unknown>;
|
|
29
|
+
/** Build the document the unauthenticated config endpoint may return. */
|
|
30
|
+
export declare const advertisedConfig: <C extends object>(config: C) => Record<string, unknown>;
|
|
31
|
+
//# sourceMappingURL=plugins.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugins.d.ts","sourceRoot":"","sources":["../src/plugins.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,MAAM,MAAM,eAAe,GAAG,IAAI,GAAG,kBAAkB,GAAG,gBAAgB,CAAA;AAE1E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAAA;CACpD;AAED,mFAAmF;AACnF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAA;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAA;CAC7C;AAED,0EAA0E;AAC1E,MAAM,WAAW,eAAe;IAC9B,sDAAsD;IACtD,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAA;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,IAAI,CAAC,EAAE,eAAe,CAAA;CAChC;AAaD,wDAAwD;AACxD,eAAO,MAAM,KAAK,cAAe,eAAe,UAAU,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,KAAG,gBAC3B,CAAA;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,eAAe,WAAY,eAAe,KAAG,IAEzD,CAAA;AAoGD,4FAA4F;AAC5F,eAAO,MAAM,eAAe,WAAY,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,UAAU,eAAe,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAQhH,CAAA;AAED,yEAAyE;AACzE,eAAO,MAAM,gBAAgB,GAAI,CAAC,SAAS,MAAM,UAAU,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAIlF,CAAA"}
|
package/build/plugins.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const plugins = [];
|
|
2
|
+
const OMIT = Symbol('api-config:omit');
|
|
3
|
+
const isRecord = (value) => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
const isEvery = (selection) => selection !== true && 'every' in selection;
|
|
5
|
+
/** Build a selector for lists and string-keyed maps. */
|
|
6
|
+
export const every = (selection, where) => ({ every: selection, ...(where != null ? { where } : {}) });
|
|
7
|
+
/**
|
|
8
|
+
* Register a package's public config contract during module import.
|
|
9
|
+
*
|
|
10
|
+
* The registry is intentionally additive: importing a package can only expose the fields it
|
|
11
|
+
* names, while a package's `deny` selector can still remove sensitive nested values.
|
|
12
|
+
*/
|
|
13
|
+
export const apiConfigPlugin = (plugin) => {
|
|
14
|
+
plugins.push(plugin);
|
|
15
|
+
};
|
|
16
|
+
const select = (value, selection) => {
|
|
17
|
+
if (value === undefined) {
|
|
18
|
+
return OMIT;
|
|
19
|
+
}
|
|
20
|
+
if (selection === true) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
if (isEvery(selection)) {
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
return value.flatMap(item => {
|
|
26
|
+
if (selection.where?.(item) === false) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const selected = select(item, selection.every);
|
|
30
|
+
return selected === OMIT ? [] : [selected];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (isRecord(value)) {
|
|
34
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
|
|
35
|
+
if (selection.where?.(item) === false) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const selected = select(item, selection.every);
|
|
39
|
+
return selected === OMIT ? [] : [[key, selected]];
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
return OMIT;
|
|
43
|
+
}
|
|
44
|
+
if (!isRecord(value)) {
|
|
45
|
+
return OMIT;
|
|
46
|
+
}
|
|
47
|
+
return Object.fromEntries(Object.entries(selection).flatMap(([key, nested]) => {
|
|
48
|
+
if (nested == null) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const selected = select(value[key], nested);
|
|
52
|
+
return selected === OMIT ? [] : [[key, selected]];
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
const redact = (value, selection) => {
|
|
56
|
+
if (selection === true) {
|
|
57
|
+
return OMIT;
|
|
58
|
+
}
|
|
59
|
+
if (isEvery(selection)) {
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
return value.flatMap(item => {
|
|
62
|
+
if (selection.where?.(item) === false) {
|
|
63
|
+
return [item];
|
|
64
|
+
}
|
|
65
|
+
const redacted = redact(item, selection.every);
|
|
66
|
+
return redacted === OMIT ? [] : [redacted];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (isRecord(value)) {
|
|
70
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
|
|
71
|
+
if (selection.where?.(item) === false) {
|
|
72
|
+
return [[key, item]];
|
|
73
|
+
}
|
|
74
|
+
const redacted = redact(item, selection.every);
|
|
75
|
+
return redacted === OMIT ? [] : [[key, redacted]];
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
return value.map(item => redact(item, selection)).filter(item => item !== OMIT);
|
|
82
|
+
}
|
|
83
|
+
if (!isRecord(value)) {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
|
|
87
|
+
const nested = selection[key];
|
|
88
|
+
if (nested == null) {
|
|
89
|
+
return [[key, item]];
|
|
90
|
+
}
|
|
91
|
+
const redacted = redact(item, nested);
|
|
92
|
+
return redacted === OMIT ? [] : [[key, redacted]];
|
|
93
|
+
}));
|
|
94
|
+
};
|
|
95
|
+
const merge = (target, source) => {
|
|
96
|
+
Object.entries(source).forEach(([key, value]) => {
|
|
97
|
+
const current = target[key];
|
|
98
|
+
if (Array.isArray(current) && Array.isArray(value)) {
|
|
99
|
+
target[key] = [...current, ...value];
|
|
100
|
+
}
|
|
101
|
+
else if (isRecord(current) && isRecord(value)) {
|
|
102
|
+
target[key] = merge({ ...current }, value);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
target[key] = value;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return target;
|
|
109
|
+
};
|
|
110
|
+
/** Apply one package contract without registering it; useful for focused contract tests. */
|
|
111
|
+
export const selectApiConfig = (config, plugin) => {
|
|
112
|
+
const selected = select(config, plugin.allow);
|
|
113
|
+
if (!isRecord(selected)) {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
const redacted = plugin.deny == null ? selected : redact(selected, plugin.deny);
|
|
117
|
+
return isRecord(redacted) ? redacted : {};
|
|
118
|
+
};
|
|
119
|
+
/** Build the document the unauthenticated config endpoint may return. */
|
|
120
|
+
export const advertisedConfig = (config) => plugins.reduce((result, plugin) => merge(result, selectApiConfig(config, plugin)), {});
|
|
121
|
+
//# sourceMappingURL=plugins.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugins.js","sourceRoot":"","sources":["../src/plugins.ts"],"names":[],"mappings":"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"}
|
package/build/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommonConfig } from '@owlmeans/config';
|
|
2
|
-
|
|
2
|
+
/** The public subset registered by packages through apiConfigPlugin(). */
|
|
3
|
+
export interface ApiConfig extends Partial<CommonConfig> {
|
|
3
4
|
}
|
|
4
5
|
//# sourceMappingURL=types.d.ts.map
|
package/build/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAEpD,MAAM,WAAW,SAAU,SAAQ,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAEpD,0EAA0E;AAC1E,MAAM,WAAW,SAAU,SAAQ,OAAO,CAAC,YAAY,CAAC;CACvD"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/api-config",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.13",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -b",
|
|
8
|
+
"test": "bun test ./tests",
|
|
8
9
|
"dev": "sleep 6 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
9
10
|
"watch": "tsc -b -w --preserveWatchOutput --pretty"
|
|
10
11
|
},
|
|
@@ -21,9 +22,10 @@
|
|
|
21
22
|
}
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@owlmeans/config": "^0.1.18-rc.
|
|
25
|
-
"@owlmeans/
|
|
26
|
-
"@owlmeans/
|
|
25
|
+
"@owlmeans/config": "^0.1.18-rc.13",
|
|
26
|
+
"@owlmeans/context": "^0.1.18-rc.9",
|
|
27
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.12",
|
|
28
|
+
"@owlmeans/route": "^0.1.18-rc.10"
|
|
27
29
|
},
|
|
28
30
|
"devDependencies": {
|
|
29
31
|
"@owlmeans/dep-config": "workspace:*",
|
package/src/consts.ts
CHANGED
|
@@ -1,12 +1,2 @@
|
|
|
1
1
|
|
|
2
2
|
export const API_CONFIG = 'api-config:advertise'
|
|
3
|
-
|
|
4
|
-
export const notAdvertizedConfigKeys = [
|
|
5
|
-
'dbs', 'trusted', 'ready', 'service', 'type',
|
|
6
|
-
'records', 'webService', 'oidc', 'storageBuckets',
|
|
7
|
-
'secrets',
|
|
8
|
-
]
|
|
9
|
-
|
|
10
|
-
export const allowedConfigRecords = [
|
|
11
|
-
'plan', 'product', 'l10n'
|
|
12
|
-
]
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AppType } from '@owlmeans/context'
|
|
2
|
+
import { PLUGINS } from '@owlmeans/config'
|
|
3
|
+
import { apiConfigPlugin, every } from './plugins.js'
|
|
4
|
+
|
|
5
|
+
const isPublicService = (value: unknown): boolean => {
|
|
6
|
+
if (value == null || typeof value !== 'object') {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
const service = value as { host?: unknown, internalHost?: unknown }
|
|
10
|
+
|
|
11
|
+
// sservice() copies an internal address into host/port. Keep a service only when it has an
|
|
12
|
+
// independently declared public address; otherwise an internal-only peer leaks through host.
|
|
13
|
+
return service.internalHost == null || service.host !== service.internalHost
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
apiConfigPlugin({
|
|
17
|
+
allow: {
|
|
18
|
+
debug: { all: true, i18n: true, supervisor: true },
|
|
19
|
+
brand: true,
|
|
20
|
+
security: {
|
|
21
|
+
unsecure: true,
|
|
22
|
+
auth: { flow: true, enter: true, login: true },
|
|
23
|
+
},
|
|
24
|
+
services: every({
|
|
25
|
+
service: true,
|
|
26
|
+
type: true,
|
|
27
|
+
host: true,
|
|
28
|
+
port: true,
|
|
29
|
+
base: true,
|
|
30
|
+
home: true,
|
|
31
|
+
default: true,
|
|
32
|
+
}, isPublicService),
|
|
33
|
+
[PLUGINS]: every(true, value =>
|
|
34
|
+
value != null && typeof value === 'object' && (value as { type?: AppType }).type === AppType.Frontend
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
})
|
package/src/index.ts
CHANGED
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** A declarative selection of config values that may cross into a browser. */
|
|
2
|
+
export type ConfigSelection = true | ConfigSelectionMap | EveryConfigValue
|
|
3
|
+
|
|
4
|
+
export interface ConfigSelectionMap {
|
|
5
|
+
readonly [key: string]: ConfigSelection | undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Apply a selection to every item in an array or every value in an object map. */
|
|
9
|
+
export interface EveryConfigValue {
|
|
10
|
+
readonly every: ConfigSelection
|
|
11
|
+
readonly where?: (value: unknown) => boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A package-owned contribution to the public runtime-config document. */
|
|
15
|
+
export interface ApiConfigPlugin {
|
|
16
|
+
/** Fields this package intentionally makes public. */
|
|
17
|
+
readonly allow: ConfigSelection
|
|
18
|
+
/** Nested fields to remove after selection, even when an ancestor is allowed. */
|
|
19
|
+
readonly deny?: ConfigSelection
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const plugins: ApiConfigPlugin[] = []
|
|
23
|
+
const OMIT = Symbol('api-config:omit')
|
|
24
|
+
|
|
25
|
+
type SelectionResult = unknown | typeof OMIT
|
|
26
|
+
|
|
27
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
|
+
value != null && typeof value === 'object' && !Array.isArray(value)
|
|
29
|
+
|
|
30
|
+
const isEvery = (selection: ConfigSelection): selection is EveryConfigValue =>
|
|
31
|
+
selection !== true && 'every' in selection
|
|
32
|
+
|
|
33
|
+
/** Build a selector for lists and string-keyed maps. */
|
|
34
|
+
export const every = (selection: ConfigSelection, where?: (value: unknown) => boolean): EveryConfigValue =>
|
|
35
|
+
({ every: selection, ...(where != null ? { where } : {}) })
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Register a package's public config contract during module import.
|
|
39
|
+
*
|
|
40
|
+
* The registry is intentionally additive: importing a package can only expose the fields it
|
|
41
|
+
* names, while a package's `deny` selector can still remove sensitive nested values.
|
|
42
|
+
*/
|
|
43
|
+
export const apiConfigPlugin = (plugin: ApiConfigPlugin): void => {
|
|
44
|
+
plugins.push(plugin)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const select = (value: unknown, selection: ConfigSelection): SelectionResult => {
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
return OMIT
|
|
50
|
+
}
|
|
51
|
+
if (selection === true) {
|
|
52
|
+
return value
|
|
53
|
+
}
|
|
54
|
+
if (isEvery(selection)) {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.flatMap(item => {
|
|
57
|
+
if (selection.where?.(item) === false) {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
const selected = select(item, selection.every)
|
|
61
|
+
return selected === OMIT ? [] : [selected]
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
if (isRecord(value)) {
|
|
65
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
|
|
66
|
+
if (selection.where?.(item) === false) {
|
|
67
|
+
return []
|
|
68
|
+
}
|
|
69
|
+
const selected = select(item, selection.every)
|
|
70
|
+
return selected === OMIT ? [] : [[key, selected]]
|
|
71
|
+
}))
|
|
72
|
+
}
|
|
73
|
+
return OMIT
|
|
74
|
+
}
|
|
75
|
+
if (!isRecord(value)) {
|
|
76
|
+
return OMIT
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return Object.fromEntries(Object.entries(selection).flatMap(([key, nested]) => {
|
|
80
|
+
if (nested == null) {
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
const selected = select(value[key], nested)
|
|
84
|
+
return selected === OMIT ? [] : [[key, selected]]
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const redact = (value: unknown, selection: ConfigSelection): SelectionResult => {
|
|
89
|
+
if (selection === true) {
|
|
90
|
+
return OMIT
|
|
91
|
+
}
|
|
92
|
+
if (isEvery(selection)) {
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
return value.flatMap(item => {
|
|
95
|
+
if (selection.where?.(item) === false) {
|
|
96
|
+
return [item]
|
|
97
|
+
}
|
|
98
|
+
const redacted = redact(item, selection.every)
|
|
99
|
+
return redacted === OMIT ? [] : [redacted]
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
if (isRecord(value)) {
|
|
103
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
|
|
104
|
+
if (selection.where?.(item) === false) {
|
|
105
|
+
return [[key, item]]
|
|
106
|
+
}
|
|
107
|
+
const redacted = redact(item, selection.every)
|
|
108
|
+
return redacted === OMIT ? [] : [[key, redacted]]
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
return value
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
return value.map(item => redact(item, selection)).filter(item => item !== OMIT)
|
|
115
|
+
}
|
|
116
|
+
if (!isRecord(value)) {
|
|
117
|
+
return value
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
|
|
121
|
+
const nested = selection[key]
|
|
122
|
+
if (nested == null) {
|
|
123
|
+
return [[key, item]]
|
|
124
|
+
}
|
|
125
|
+
const redacted = redact(item, nested)
|
|
126
|
+
return redacted === OMIT ? [] : [[key, redacted]]
|
|
127
|
+
}))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const merge = (target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> => {
|
|
131
|
+
Object.entries(source).forEach(([key, value]) => {
|
|
132
|
+
const current = target[key]
|
|
133
|
+
if (Array.isArray(current) && Array.isArray(value)) {
|
|
134
|
+
target[key] = [...current, ...value]
|
|
135
|
+
} else if (isRecord(current) && isRecord(value)) {
|
|
136
|
+
target[key] = merge({ ...current }, value)
|
|
137
|
+
} else {
|
|
138
|
+
target[key] = value
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return target
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Apply one package contract without registering it; useful for focused contract tests. */
|
|
146
|
+
export const selectApiConfig = (config: Record<string, unknown>, plugin: ApiConfigPlugin): Record<string, unknown> => {
|
|
147
|
+
const selected = select(config, plugin.allow)
|
|
148
|
+
if (!isRecord(selected)) {
|
|
149
|
+
return {}
|
|
150
|
+
}
|
|
151
|
+
const redacted = plugin.deny == null ? selected : redact(selected, plugin.deny)
|
|
152
|
+
|
|
153
|
+
return isRecord(redacted) ? redacted : {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Build the document the unauthenticated config endpoint may return. */
|
|
157
|
+
export const advertisedConfig = <C extends object>(config: C): Record<string, unknown> =>
|
|
158
|
+
plugins.reduce(
|
|
159
|
+
(result, plugin) => merge(result, selectApiConfig(config as Record<string, unknown>, plugin)),
|
|
160
|
+
{} as Record<string, unknown>
|
|
161
|
+
)
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { CommonConfig } from '@owlmeans/config'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
'dbs' | 'trusted' | 'ready' | 'service' | 'type'
|
|
6
|
-
> {
|
|
3
|
+
/** The public subset registered by packages through apiConfigPlugin(). */
|
|
4
|
+
export interface ApiConfig extends Partial<CommonConfig> {
|
|
7
5
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AppType, CONFIG_RECORD } from '@owlmeans/context'
|
|
3
|
+
import '@owlmeans/flow'
|
|
4
|
+
import '@owlmeans/i18n'
|
|
5
|
+
import '@owlmeans/oidc'
|
|
6
|
+
import '@owlmeans/payment'
|
|
7
|
+
import { advertisedConfig, every, selectApiConfig } from '@owlmeans/api-config'
|
|
8
|
+
import type { CommonConfig } from '@owlmeans/config'
|
|
9
|
+
|
|
10
|
+
describe('@owlmeans/api-config plugins', () => {
|
|
11
|
+
test('keeps only registered config and public nested fields', () => {
|
|
12
|
+
const result = advertisedConfig({
|
|
13
|
+
debug: { all: true, supervisor: true, serverTrace: 'private' },
|
|
14
|
+
brand: { name: 'Example' },
|
|
15
|
+
security: {
|
|
16
|
+
unsecure: false,
|
|
17
|
+
auth: { login: { title: 'Sign in' }, serverOnly: 'private' },
|
|
18
|
+
},
|
|
19
|
+
services: {
|
|
20
|
+
api: {
|
|
21
|
+
service: 'api', type: AppType.Backend, host: 'api.example.test', port: 443,
|
|
22
|
+
base: 'api', internalHost: 'api.cluster.local', serverOnly: 'private',
|
|
23
|
+
},
|
|
24
|
+
worker: {
|
|
25
|
+
service: 'worker', type: AppType.Backend, host: 'worker.cluster.local',
|
|
26
|
+
internalHost: 'worker.cluster.local', port: 3000,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
plugins: [
|
|
30
|
+
{ id: 'web', type: AppType.Frontend, value: 'safe' },
|
|
31
|
+
{ id: 'server', type: AppType.Backend, value: 'private' },
|
|
32
|
+
],
|
|
33
|
+
oidc: {
|
|
34
|
+
clientCookie: { interaction: { name: 'oidc', ttl: 60 } },
|
|
35
|
+
accountLinkingService: 'private',
|
|
36
|
+
providers: [
|
|
37
|
+
{ clientId: 'public', service: 'iam', secret: 'secret', apiClientId: 'admin' },
|
|
38
|
+
{ clientId: 'internal', service: 'iam', internal: true, secret: 'secret' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
flowConfig: { defaultFlow: 'signin', services: { auth: 'web' }, serverOnly: 'private' },
|
|
42
|
+
i18n: { defaultLng: 'en', supportedLngs: ['en', 'pl'], serverOnly: 'private' },
|
|
43
|
+
[CONFIG_RECORD]: [
|
|
44
|
+
{ id: 'plan:starter', recordType: 'plan', price: 10 },
|
|
45
|
+
{ id: 'flow:signin', recordType: 'flow' },
|
|
46
|
+
],
|
|
47
|
+
smtp: { host: 'smtp.example.test', pass: 'secret' },
|
|
48
|
+
queue: { queues: [{ name: 'private' }] },
|
|
49
|
+
dbs: [{ host: 'db.example.test', secret: 'secret' }],
|
|
50
|
+
secrets: { key: 'secret' },
|
|
51
|
+
} as unknown as CommonConfig)
|
|
52
|
+
|
|
53
|
+
expect(result).toEqual({
|
|
54
|
+
debug: { all: true, supervisor: true },
|
|
55
|
+
brand: { name: 'Example' },
|
|
56
|
+
security: { unsecure: false, auth: { login: { title: 'Sign in' } } },
|
|
57
|
+
services: {
|
|
58
|
+
api: { service: 'api', type: AppType.Backend, host: 'api.example.test', port: 443, base: 'api' },
|
|
59
|
+
},
|
|
60
|
+
plugins: [{ id: 'web', type: AppType.Frontend, value: 'safe' }],
|
|
61
|
+
oidc: {
|
|
62
|
+
clientCookie: { interaction: { name: 'oidc', ttl: 60 } },
|
|
63
|
+
providers: [{ clientId: 'public', service: 'iam' }],
|
|
64
|
+
},
|
|
65
|
+
flowConfig: { defaultFlow: 'signin', services: { auth: 'web' } },
|
|
66
|
+
i18n: { defaultLng: 'en', supportedLngs: ['en', 'pl'] },
|
|
67
|
+
[CONFIG_RECORD]: [{ id: 'plan:starter', recordType: 'plan', price: 10 }],
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('can remove nested secrets after allowing a collection', () => {
|
|
72
|
+
const result = selectApiConfig({
|
|
73
|
+
integrations: [
|
|
74
|
+
{ name: 'public', token: 'secret', options: { endpoint: 'https://example.test', key: 'private' } },
|
|
75
|
+
],
|
|
76
|
+
}, {
|
|
77
|
+
allow: { integrations: every(true) },
|
|
78
|
+
deny: { integrations: every({ token: true, options: { key: true } }) },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(result).toEqual({
|
|
82
|
+
integrations: [{ name: 'public', options: { endpoint: 'https://example.test' } }],
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
})
|
package/tsconfig.json
CHANGED