@fabrika/installation-init 0.8.2 → 0.9.0-cloudflare.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/package.json +9 -6
- package/src/index.ts +33 -0
- package/src/mcp.ts +102 -0
- package/src/pin.ts +26 -0
- package/src/proxy-manifest.ts +221 -0
- package/src/secrets-file.ts +61 -0
- package/src/secrets.ts +78 -0
- package/src/service-environment.ts +206 -0
- package/types/src/admin.d.ts +91 -0
- package/types/src/checkout.d.ts +9 -0
- package/types/src/confirmations.d.ts +21 -0
- package/types/src/email.d.ts +15 -0
- package/types/src/environment.d.ts +49 -0
- package/types/src/gh.d.ts +9 -0
- package/types/src/index.d.ts +18 -0
- package/types/src/log.d.ts +29 -0
- package/types/src/mcp.d.ts +18 -0
- package/types/src/options.d.ts +9 -0
- package/types/src/pin.d.ts +2 -0
- package/types/src/prompt.d.ts +11 -0
- package/types/src/protection.d.ts +57 -0
- package/types/src/proxy-manifest.d.ts +109 -0
- package/types/src/scaffold.d.ts +49 -0
- package/types/src/secrets-file.d.ts +18 -0
- package/types/src/secrets.d.ts +32 -0
- package/types/src/service-environment.d.ts +78 -0
- package/types/src/shell.d.ts +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/installation-init",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-cloudflare.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
-
"!src/**/__tests__/**"
|
|
15
|
+
"!src/**/__tests__/**",
|
|
16
|
+
"types"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
19
|
".": {
|
|
19
20
|
"bun": "./src/index.ts",
|
|
20
|
-
"types": "./src/index.ts",
|
|
21
|
+
"types": "./types/src/index.d.ts",
|
|
21
22
|
"default": "./src/index.ts"
|
|
22
23
|
}
|
|
23
24
|
},
|
|
@@ -27,8 +28,10 @@
|
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
30
|
"zod": "^3.25.76",
|
|
30
|
-
"@fabrika/app": "0.
|
|
31
|
-
"@fabrika/
|
|
32
|
-
"@fabrika/
|
|
31
|
+
"@fabrika/app": "0.9.0-cloudflare.1",
|
|
32
|
+
"@fabrika/auth-core": "0.9.0-cloudflare.1",
|
|
33
|
+
"@fabrika/email": "0.9.0-cloudflare.1",
|
|
34
|
+
"@fabrika/iam-contract": "0.9.0-cloudflare.1",
|
|
35
|
+
"@fabrika/proxy-core": "0.9.0-cloudflare.1"
|
|
33
36
|
}
|
|
34
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,9 @@ export {
|
|
|
22
22
|
} from './environment'
|
|
23
23
|
export { ghRepoExists, hasGhCli } from './gh'
|
|
24
24
|
export { action, detail, fail, info, ok, step, url, warn } from './log'
|
|
25
|
+
export { type IamOAuthConfig, type McpClient, parseMcpClientsJson, platformIamOAuthConfig } from './mcp'
|
|
25
26
|
export { type CommandEnv, describeArgument, readEnv, readFlag, requiredSetting, setting, shellWord } from './options'
|
|
27
|
+
export { assertPinnedTag } from './pin'
|
|
26
28
|
export { confirm } from './prompt'
|
|
27
29
|
export {
|
|
28
30
|
branchProtectionCommand,
|
|
@@ -36,6 +38,21 @@ export {
|
|
|
36
38
|
type ProtectionOutcome,
|
|
37
39
|
protectSidecarBranch,
|
|
38
40
|
} from './protection'
|
|
41
|
+
export {
|
|
42
|
+
type PlatformHosts,
|
|
43
|
+
type PlatformOAuthResourceTemplate,
|
|
44
|
+
type PlatformOAuthScopeGrant,
|
|
45
|
+
platformOrigin,
|
|
46
|
+
type PlatformProxyApp,
|
|
47
|
+
platformProxyAppFor,
|
|
48
|
+
type PlatformProxyComposition,
|
|
49
|
+
type PlatformProxyManifest,
|
|
50
|
+
type PlatformProxyManifestTemplate,
|
|
51
|
+
type PlatformProxyPlacement,
|
|
52
|
+
type PlatformProxyService,
|
|
53
|
+
platformServiceTableDigest,
|
|
54
|
+
resolvePlatformProxyManifest,
|
|
55
|
+
} from './proxy-manifest'
|
|
39
56
|
export {
|
|
40
57
|
INITIAL_SCAFFOLD_COMMIT_MESSAGE,
|
|
41
58
|
REFRESH_SCAFFOLD_COMMIT_MESSAGE,
|
|
@@ -44,4 +61,20 @@ export {
|
|
|
44
61
|
type SidecarScaffoldInput,
|
|
45
62
|
type SidecarScaffoldResult,
|
|
46
63
|
} from './scaffold'
|
|
64
|
+
export { generateInstallationSecrets, generateSigningKeys, generateVaultKey, type InstallationSecrets, randomPxKey, randomSecret } from './secrets'
|
|
65
|
+
export { checkedSecretsFilePath, renderSecretsFile, SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE, writeSecretsFile } from './secrets-file'
|
|
66
|
+
export {
|
|
67
|
+
deployedServiceEnvironment,
|
|
68
|
+
type DeployedServiceInput,
|
|
69
|
+
deriveIamProxySecret,
|
|
70
|
+
IAM_OAUTH_CONFIG_VARIABLE,
|
|
71
|
+
INSTALLATION_SECRET_VARIABLES,
|
|
72
|
+
installationServiceEnvironment,
|
|
73
|
+
type InstallationServiceInput,
|
|
74
|
+
type PlatformData,
|
|
75
|
+
type PlatformPlacementInput,
|
|
76
|
+
type PlatformService,
|
|
77
|
+
type PlatformServiceEnvironment,
|
|
78
|
+
type PlatformStorage,
|
|
79
|
+
} from './service-environment'
|
|
47
80
|
export { attempt, capture, probe, run, type ShellAttempt, type ShellStep } from './shell'
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// MCP authoring, opt-in per installation (ADR-0049).
|
|
2
|
+
//
|
|
3
|
+
// The operator names the MCP clients it trusts; everything else is derived. One input turns on BOTH
|
|
4
|
+
// halves, so they cannot disagree: the console's `oauth` row with its registered resource in the proxy
|
|
5
|
+
// manifest, and IAM's `FABRIKA_IAM_OAUTH_CONFIG`, which issues tokens for exactly that resource.
|
|
6
|
+
|
|
7
|
+
import { type OAuthManagedClient, type OAuthResourceDto, parseOAuthManagedClient } from '@fabrika/iam-contract'
|
|
8
|
+
import { platformOAuthResourceUris, type PlatformProxyComposition, type PlatformProxyManifestTemplate } from './proxy-manifest'
|
|
9
|
+
|
|
10
|
+
/** One operator-registered MCP client: its CIMD identity, plus where IAM fetches it when that differs. */
|
|
11
|
+
export interface McpClient extends OAuthManagedClient {
|
|
12
|
+
readonly metadataUrl?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The document IAM parses from `FABRIKA_IAM_OAUTH_CONFIG`. */
|
|
16
|
+
export interface IamOAuthConfig {
|
|
17
|
+
readonly clients: readonly McpClient[]
|
|
18
|
+
readonly resources: readonly OAuthResourceDto[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CLIENT_KEYS = ['provider', 'clientId', 'redirectUris', 'metadataUrl']
|
|
22
|
+
/** IAM's own ceiling: it refuses to boot with more. */
|
|
23
|
+
const MAX_CLIENTS = 16
|
|
24
|
+
|
|
25
|
+
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
|
+
|
|
27
|
+
const isHttpsUrl = (value: string): boolean => {
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(value)
|
|
30
|
+
return url.protocol === 'https:' && url.username === '' && url.password === '' && url.hash === ''
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parseClient = (value: unknown, index: number): McpClient => {
|
|
37
|
+
const client = parseOAuthManagedClient(value)
|
|
38
|
+
if (client === null || !isObject(value) || !Object.keys(value).every((key) => CLIENT_KEYS.includes(key))) {
|
|
39
|
+
throw new Error(`MCP client ${index} must be { provider: 'claude' | 'chatgpt', clientId: <https URL>, redirectUris: [...], metadataUrl? }`)
|
|
40
|
+
}
|
|
41
|
+
const metadataUrl = value['metadataUrl']
|
|
42
|
+
if (metadataUrl === undefined) {
|
|
43
|
+
return client
|
|
44
|
+
}
|
|
45
|
+
if (typeof metadataUrl !== 'string' || !isHttpsUrl(metadataUrl)) {
|
|
46
|
+
throw new Error(`MCP client ${index}: metadataUrl must be an https URL`)
|
|
47
|
+
}
|
|
48
|
+
return { ...client, metadataUrl }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse the operator's MCP client list — a JSON array. Anything malformed, an empty list or a repeated
|
|
53
|
+
* client id is refused here, before a deploy writes a value IAM would refuse to boot with.
|
|
54
|
+
*/
|
|
55
|
+
export const parseMcpClientsJson = (raw: string): readonly McpClient[] => {
|
|
56
|
+
let value: unknown
|
|
57
|
+
try {
|
|
58
|
+
value = JSON.parse(raw)
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error('the MCP client list is not valid JSON')
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
63
|
+
throw new Error('the MCP client list must be a non-empty JSON array')
|
|
64
|
+
}
|
|
65
|
+
if (value.length > MAX_CLIENTS) {
|
|
66
|
+
throw new Error(`the MCP client list names more than ${MAX_CLIENTS} clients, which IAM refuses`)
|
|
67
|
+
}
|
|
68
|
+
const clients = value.map(parseClient)
|
|
69
|
+
const ids = new Set(clients.map((client) => client.clientId))
|
|
70
|
+
if (ids.size !== clients.length) {
|
|
71
|
+
throw new Error('the MCP client list names one client id twice')
|
|
72
|
+
}
|
|
73
|
+
return clients
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** IAM's OAuth configuration for one placed installation: the clients, and one resource per app that serves one. */
|
|
77
|
+
export const platformIamOAuthConfig = (
|
|
78
|
+
template: PlatformProxyManifestTemplate,
|
|
79
|
+
composition: PlatformProxyComposition,
|
|
80
|
+
clients: readonly McpClient[],
|
|
81
|
+
): IamOAuthConfig => {
|
|
82
|
+
const resources: OAuthResourceDto[] = []
|
|
83
|
+
for (const app of template.apps) {
|
|
84
|
+
if (app.oauthResource === undefined) {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const { resource } = platformOAuthResourceUris(composition, composition.placement[app.service].hosts, app.oauthResource)
|
|
88
|
+
resources.push({
|
|
89
|
+
app: app.id,
|
|
90
|
+
resource,
|
|
91
|
+
defaultScopes: [...app.oauthResource.scopes],
|
|
92
|
+
scopeGrants: app.oauthResource.scopeGrants.map((grant) => ({
|
|
93
|
+
scope: grant.scope,
|
|
94
|
+
permissions: grant.actions.map((action) => ({ action })),
|
|
95
|
+
})),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
if (resources.length === 0) {
|
|
99
|
+
throw new Error('the platform proxy manifest template declares no OAuth resource for MCP clients to reach')
|
|
100
|
+
}
|
|
101
|
+
return { clients, resources }
|
|
102
|
+
}
|
package/src/pin.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The one rule about a sidecar's pin: it names a published TAG of `contember/fabrika-platform`, never a
|
|
2
|
+
// branch. A namespace proxy and an installation both build from tags, so a moving ref would make "which
|
|
3
|
+
// version is this installation running" unanswerable (ADR-0025).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A published release tag: `v` and a semantic version.
|
|
7
|
+
*
|
|
8
|
+
* The same shape `scripts/release.ts` accepts, restated rather than imported — that script is not a
|
|
9
|
+
* package and this check has to hold in a published tarball.
|
|
10
|
+
*/
|
|
11
|
+
const TAG_PATTERN = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
12
|
+
|
|
13
|
+
/** The pinned ref, or an error naming what was found. A branch is refused, deliberately and loudly. */
|
|
14
|
+
export const assertPinnedTag = (ref: string): string => {
|
|
15
|
+
const trimmed = ref.trim()
|
|
16
|
+
if (trimmed === '') {
|
|
17
|
+
throw new Error('a published fabrika tag is required, for example v0.1.0')
|
|
18
|
+
}
|
|
19
|
+
if (!TAG_PATTERN.test(trimmed)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`\`${trimmed}\` is not a published tag. A sidecar repository pins a TAG such as v0.1.0 and never a branch: `
|
|
22
|
+
+ 'an installation that tracked a moving ref could not say which version it runs (ADR-0025)',
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
return trimmed
|
|
26
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// The proxy manifest a platform installation is configured with, split into the half that is the same
|
|
2
|
+
// on every installation and the half that is one account's.
|
|
3
|
+
//
|
|
4
|
+
// ── Why a template rather than a `ProxyManifest` with the hosts left out ──────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// `parseProxyManifest` refuses an app that declares no hosts, and rightly — a host-less app is fronted
|
|
7
|
+
// by nothing and protects nothing. So the committed half cannot BE a manifest with empty hosts; it is a
|
|
8
|
+
// type of its own, and `resolvePlatformProxyManifest` is the one way to turn it into the document the
|
|
9
|
+
// proxy parses.
|
|
10
|
+
//
|
|
11
|
+
// ── The split ────────────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// template which apps exist, their ids, the private service each is dialed at, the public listener
|
|
14
|
+
// each answers on, and their service table. True of every installation of one provider, so the
|
|
15
|
+
// provider ships it GENERATED (Zerops: `provider-zerops/src/installation/generated/`) and reviewed in
|
|
16
|
+
// a diff.
|
|
17
|
+
// placement the hostnames and the browser-facing scheme. A generated cloud hostname names one
|
|
18
|
+
// account's project, and a production installation binds custom domains instead, so
|
|
19
|
+
// whoever is deploying supplies these.
|
|
20
|
+
//
|
|
21
|
+
// Nothing in this file imports the packages that OWN the tables. That is the point of the split:
|
|
22
|
+
// they live in the private service packages and reach a deployed installation as DATA, through the
|
|
23
|
+
// generated artifact, so the published deploy path never depends on a package it cannot resolve.
|
|
24
|
+
|
|
25
|
+
import { type InstallationEndpointTable, installationTableDigest } from '@fabrika/auth-core'
|
|
26
|
+
import {
|
|
27
|
+
type InstallationProxyApp,
|
|
28
|
+
isApplicationEntry,
|
|
29
|
+
type OAuthProtectedResource,
|
|
30
|
+
parseProxyManifest,
|
|
31
|
+
serviceProxyTable,
|
|
32
|
+
} from '@fabrika/proxy-core/manifest'
|
|
33
|
+
|
|
34
|
+
/** The platform services an installation's proxy fronts. Each name is a service hostname in the topology. */
|
|
35
|
+
export type PlatformProxyService = 'iam' | 'control' | 'operations'
|
|
36
|
+
|
|
37
|
+
/** The public hostname of each platform service, without a scheme and without a port. */
|
|
38
|
+
export type PlatformHosts = Readonly<Record<PlatformProxyService, string>>
|
|
39
|
+
|
|
40
|
+
/** `https://<host>` — the origin a browser, a token's `iss` and a return-origin registry all use. */
|
|
41
|
+
export const platformOrigin = (scheme: 'http' | 'https', host: string): string => `${scheme}://${host}`
|
|
42
|
+
|
|
43
|
+
/** What one OAuth scope lets a token do: the owning app's actions, resolved by IAM at the token endpoint. */
|
|
44
|
+
export interface PlatformOAuthScopeGrant {
|
|
45
|
+
readonly scope: string
|
|
46
|
+
readonly actions: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A path an app serves to OAuth clients, protected only on an installation that enables it.
|
|
51
|
+
*
|
|
52
|
+
* The resource URI is the app's public origin plus `path`, so the template holds the path and a deploy
|
|
53
|
+
* binds the origin. Copied from the owning service module by the generator, like `table`.
|
|
54
|
+
*/
|
|
55
|
+
export interface PlatformOAuthResourceTemplate {
|
|
56
|
+
readonly path: string
|
|
57
|
+
readonly scopes: readonly string[]
|
|
58
|
+
readonly scopeGrants: readonly PlatformOAuthScopeGrant[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One fronted app, in the form that is the same on every installation: protected by its service contract (ADR-0022
|
|
63
|
+
* "Installation entries"), copied in by the generator and never written by hand.
|
|
64
|
+
*/
|
|
65
|
+
export interface PlatformProxyApp {
|
|
66
|
+
/** The IAM app id. It is the audience of every token the proxy mints for this app's host. */
|
|
67
|
+
readonly id: string
|
|
68
|
+
/** The topology service that answers for it. */
|
|
69
|
+
readonly service: PlatformProxyService
|
|
70
|
+
/**
|
|
71
|
+
* The proxy's public listener this app answers on.
|
|
72
|
+
*
|
|
73
|
+
* On a `zerops-subdomain` installation a port IS a hostname — Zerops mints one generated host per
|
|
74
|
+
* HTTP port — so this is the only installation-independent half of `hosts`. A custom-domain
|
|
75
|
+
* installation ignores it: every domain arrives on one listener and the `Host` header decides.
|
|
76
|
+
*/
|
|
77
|
+
readonly port: number
|
|
78
|
+
/** Where the proxy dials it over the project's private network. */
|
|
79
|
+
readonly upstream: string
|
|
80
|
+
/** The service's rows; with an `oauthResource`, the table served when the composition names an authorization server. */
|
|
81
|
+
readonly table: InstallationEndpointTable
|
|
82
|
+
/** Present only on an app that can serve OAuth clients; off unless the composition names an authorization server. */
|
|
83
|
+
readonly oauthResource?: PlatformOAuthResourceTemplate
|
|
84
|
+
/**
|
|
85
|
+
* Present exactly with `oauthResource`: the service's table when it serves no OAuth client. The service picks the
|
|
86
|
+
* same one, by the digest the deploy writes beside it (`platformServiceTableDigest`).
|
|
87
|
+
*/
|
|
88
|
+
readonly tableWithoutOAuth?: InstallationEndpointTable
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Everything about an installation's manifest that does not depend on WHICH installation it is. */
|
|
92
|
+
export interface PlatformProxyManifestTemplate {
|
|
93
|
+
readonly apps: readonly PlatformProxyApp[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The template's entry for one platform service.
|
|
98
|
+
*
|
|
99
|
+
* The app IDS live in the template, so a caller that needs one — the deploy's schema reconcile needs
|
|
100
|
+
* the console's — reads it from there rather than restating a literal that could drift from the
|
|
101
|
+
* document the proxy is actually configured with.
|
|
102
|
+
*/
|
|
103
|
+
export const platformProxyAppFor = (
|
|
104
|
+
template: PlatformProxyManifestTemplate,
|
|
105
|
+
service: PlatformProxyService,
|
|
106
|
+
): PlatformProxyApp => {
|
|
107
|
+
const app = template.apps.find((candidate) => candidate.service === service)
|
|
108
|
+
if (app === undefined) {
|
|
109
|
+
throw new Error(`the platform proxy manifest template fronts no \`${service}\` service`)
|
|
110
|
+
}
|
|
111
|
+
return app
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Where one app answers in a particular composition. */
|
|
115
|
+
export interface PlatformProxyPlacement {
|
|
116
|
+
/** Public hostnames, without a port — `parseProxyManifest` refuses a `:port` suffix. */
|
|
117
|
+
readonly hosts: readonly string[]
|
|
118
|
+
/** Overrides the private dial address. The local composition runs IAM on a port of its own. */
|
|
119
|
+
readonly upstream?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One composition of the platform: a deployed installation, or the local Docker stack. */
|
|
123
|
+
export interface PlatformProxyComposition {
|
|
124
|
+
/** The scheme the BROWSER speaks to these apps — configuration, never a header (see `ProxyApp.scheme`). */
|
|
125
|
+
readonly scheme: 'http' | 'https'
|
|
126
|
+
readonly placement: Readonly<Record<PlatformProxyService, PlatformProxyPlacement>>
|
|
127
|
+
/**
|
|
128
|
+
* The public IAM origin that issues OAuth tokens. Present only when the installation enables MCP
|
|
129
|
+
* authoring; absent leaves every `oauthResource` unregistered and serves each service's table without OAuth.
|
|
130
|
+
*/
|
|
131
|
+
readonly authorizationServer?: string
|
|
132
|
+
/** The port browsers reach every app on, when it is not the scheme's default. The local composition's is 18080. */
|
|
133
|
+
readonly publicPort?: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The RFC 8707 resource URI and RFC 9728 metadata URL of an app's OAuth resource, bound to the app's
|
|
138
|
+
* FIRST host: the proxy refuses a resource on a host the app does not answer on.
|
|
139
|
+
*/
|
|
140
|
+
export const platformOAuthResourceUris = (
|
|
141
|
+
composition: Pick<PlatformProxyComposition, 'scheme' | 'publicPort'>,
|
|
142
|
+
hosts: readonly string[],
|
|
143
|
+
template: PlatformOAuthResourceTemplate,
|
|
144
|
+
): { readonly resource: string; readonly resourceMetadata: string } => {
|
|
145
|
+
const host = hosts[0]
|
|
146
|
+
if (host === undefined) {
|
|
147
|
+
throw new Error('an OAuth resource needs a host to stand on')
|
|
148
|
+
}
|
|
149
|
+
const origin = composition.publicPort === undefined
|
|
150
|
+
? `${composition.scheme}://${host}`
|
|
151
|
+
: `${composition.scheme}://${host}:${composition.publicPort}`
|
|
152
|
+
return {
|
|
153
|
+
resource: `${origin}${template.path}`,
|
|
154
|
+
resourceMetadata: `${origin}/.well-known/oauth-protected-resource${template.path}`,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const registeredResource = (
|
|
159
|
+
hosts: readonly string[],
|
|
160
|
+
template: PlatformOAuthResourceTemplate,
|
|
161
|
+
composition: PlatformProxyComposition,
|
|
162
|
+
authorizationServer: string,
|
|
163
|
+
): OAuthProtectedResource => ({
|
|
164
|
+
...platformOAuthResourceUris(composition, hosts, template),
|
|
165
|
+
authorizationServers: [authorizationServer],
|
|
166
|
+
scopesSupported: [...template.scopes],
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/** Only an installation that names an authorization server registers the resource and serves its OAuth rows. */
|
|
170
|
+
const resolvedApp = (app: PlatformProxyApp, composition: PlatformProxyComposition): InstallationProxyApp => {
|
|
171
|
+
const placement = composition.placement[app.service]
|
|
172
|
+
const hosts = [...placement.hosts]
|
|
173
|
+
const registered = app.oauthResource === undefined || composition.authorizationServer === undefined
|
|
174
|
+
? undefined
|
|
175
|
+
: registeredResource(hosts, app.oauthResource, composition, composition.authorizationServer)
|
|
176
|
+
const table = registered === undefined ? (app.tableWithoutOAuth ?? app.table) : app.table
|
|
177
|
+
return {
|
|
178
|
+
id: app.id,
|
|
179
|
+
hosts,
|
|
180
|
+
upstream: placement.upstream ?? app.upstream,
|
|
181
|
+
scheme: composition.scheme,
|
|
182
|
+
...serviceProxyTable(table, { oauth: registered !== undefined }),
|
|
183
|
+
...(registered === undefined ? {} : { oauthResources: [registered] }),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Bind a template to one composition and get the document the proxy actually parses.
|
|
189
|
+
*
|
|
190
|
+
* The result is returned THROUGH `parseProxyManifest`, so a duplicate id, a repeated host or a `:port`
|
|
191
|
+
* suffix is refused right here instead of at a deployed proxy's next build.
|
|
192
|
+
*/
|
|
193
|
+
export const resolvePlatformProxyManifest = (
|
|
194
|
+
template: PlatformProxyManifestTemplate,
|
|
195
|
+
composition: PlatformProxyComposition,
|
|
196
|
+
): PlatformProxyManifest => {
|
|
197
|
+
const manifest = parseProxyManifest({ apps: template.apps.map((app) => resolvedApp(app, composition)) })
|
|
198
|
+
// Every platform service is an installation entry, never an application.
|
|
199
|
+
const services = manifest?.apps.flatMap((app) => isApplicationEntry(app) ? [] : [app]) ?? []
|
|
200
|
+
if (manifest === null || services.length !== manifest.apps.length) {
|
|
201
|
+
throw new Error('the resolved platform proxy manifest is not one the proxy would accept')
|
|
202
|
+
}
|
|
203
|
+
return { apps: services }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The platform proxy's manifest: one installation entry per platform service. */
|
|
207
|
+
export interface PlatformProxyManifest {
|
|
208
|
+
apps: InstallationProxyApp[]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `FABRIKA_SERVICE_TABLE_DIGEST` for a service: the digest of the table this manifest publishes for it, which the
|
|
213
|
+
* service compares with its own and refuses to start under another.
|
|
214
|
+
*/
|
|
215
|
+
export const platformServiceTableDigest = (manifest: PlatformProxyManifest, appId: string): string => {
|
|
216
|
+
const entry = manifest.apps.find((app) => app.id === appId)
|
|
217
|
+
if (entry === undefined) {
|
|
218
|
+
throw new Error(`the platform proxy manifest fronts no \`${appId}\``)
|
|
219
|
+
}
|
|
220
|
+
return installationTableDigest(entry)
|
|
221
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The operator's local copy of every credential a bring-up creates.
|
|
2
|
+
//
|
|
3
|
+
// A cloud that stores a credential write-only hands nothing back, so this file is the copy the command
|
|
4
|
+
// writes before the first credential is placed: a run that fails later loses nothing.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { open } from 'node:fs/promises'
|
|
8
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
9
|
+
|
|
10
|
+
/** `--secrets-file`: where the local copy goes. A new file, outside the source checkout. */
|
|
11
|
+
export const SECRETS_FILE_FLAG = '--secrets-file'
|
|
12
|
+
export const SECRETS_FILE_VARIABLE = 'FABRIKA_PLATFORM_SECRETS_FILE'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Settle the path before anything is contacted.
|
|
16
|
+
*
|
|
17
|
+
* Refused when it exists, because overwriting one would destroy another installation's only copy, and
|
|
18
|
+
* when it is inside the source checkout, where it would dirty the checkout a deploy uploads and could be
|
|
19
|
+
* committed.
|
|
20
|
+
*/
|
|
21
|
+
export const checkedSecretsFilePath = (path: string, sourceDir: string): string => {
|
|
22
|
+
const absolute = resolve(path)
|
|
23
|
+
const fromSource = relative(resolve(sourceDir), absolute)
|
|
24
|
+
if (fromSource === '' || (!fromSource.startsWith('..') && !isAbsolute(fromSource))) {
|
|
25
|
+
throw new Error(`${SECRETS_FILE_FLAG}=${path} is inside the source checkout — name a path outside it, so it is never uploaded or committed`)
|
|
26
|
+
}
|
|
27
|
+
if (existsSync(absolute)) {
|
|
28
|
+
throw new Error(`${SECRETS_FILE_FLAG}=${path} already exists — it may be another installation's only copy; name a new file`)
|
|
29
|
+
}
|
|
30
|
+
return absolute
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The file's text: comment lines, then shell-sourceable assignments, named as the commands that read
|
|
35
|
+
* them name them. `header` is the provider's own lines, each written as a `#` comment.
|
|
36
|
+
*/
|
|
37
|
+
export const renderSecretsFile = (header: readonly string[], assignments: readonly (readonly [string, string])[]): string => {
|
|
38
|
+
const lines = assignments.map(([name, value]) => {
|
|
39
|
+
// Single quotes keep every character literal; none of these values can contain one.
|
|
40
|
+
if (value.includes("'") || value.includes('\n')) {
|
|
41
|
+
throw new Error(`${name} cannot be written to the secrets file as one quoted line`)
|
|
42
|
+
}
|
|
43
|
+
return `${name}='${value}'`
|
|
44
|
+
})
|
|
45
|
+
return [
|
|
46
|
+
...header.map((line) => `# ${line}`),
|
|
47
|
+
'# Load it in a subshell: ( set -a && . <this file> && set +a && fabrika … )',
|
|
48
|
+
...lines,
|
|
49
|
+
'',
|
|
50
|
+
].join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Create the file with mode 0600, failing rather than replacing one that appeared since the check. */
|
|
54
|
+
export const writeSecretsFile = async (path: string, text: string): Promise<void> => {
|
|
55
|
+
const file = await open(path, 'wx', 0o600)
|
|
56
|
+
try {
|
|
57
|
+
await file.writeFile(text)
|
|
58
|
+
} finally {
|
|
59
|
+
await file.close()
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The credentials a platform installation is seeded with, and their shapes.
|
|
2
|
+
//
|
|
3
|
+
// The shapes are not free choices. Each one is what the consuming code already parses:
|
|
4
|
+
//
|
|
5
|
+
// • `FABRIKA_IAM_SIGNING_KEYS` is a JSON array of PRIVATE JWKs, index 0 the active signer
|
|
6
|
+
// (`@fabrika/iam`'s `Signer.fromPrivateJwks`). ES256 / P-256, RFC 7638 thumbprint `kid`.
|
|
7
|
+
// • A `px_` key is IAM's own credential spelling, and every shared secret must be at least 32
|
|
8
|
+
// characters or the service refuses to boot — 32 random bytes at base64url is 43.
|
|
9
|
+
// • The vault KEK is 32 RAW BYTES at base64, not base64url: `@fabrika/control` imports it as an
|
|
10
|
+
// AES key, and base64url is a different alphabet.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here re-reads an existing value: whether a run generates or reuses one is the provider's flow.
|
|
13
|
+
|
|
14
|
+
import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A 256-bit secret in base64url, never starting with `-` or `_`.
|
|
18
|
+
*
|
|
19
|
+
* The re-roll is copied from `@fabrika/local-stack`'s `prepare.ts` and it is not superstition: a
|
|
20
|
+
* credential beginning with `-` reads as a FLAG to anything that later takes it on a command line, no
|
|
21
|
+
* matter how it is quoted. One rolled password beginning with `-` made `mc alias set` print its usage
|
|
22
|
+
* and loop forever. It costs nothing and removes a bug that appears about one install in sixteen.
|
|
23
|
+
*/
|
|
24
|
+
export const randomSecret = (): string => {
|
|
25
|
+
for (;;) {
|
|
26
|
+
const value = randomBytes(32).toString('base64url')
|
|
27
|
+
if (!value.startsWith('-') && !value.startsWith('_')) {
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The `px_` prefix IAM's own credentials carry, so a key looks like what it is wherever it turns up. */
|
|
34
|
+
export const randomPxKey = (): string => `px_${randomSecret()}`
|
|
35
|
+
|
|
36
|
+
/** The control plane's vault KEK. base64, NOT base64url: the vault imports these bytes as an AES key. */
|
|
37
|
+
export const generateVaultKey = (): string => randomBytes(32).toString('base64')
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One ES256 (EC P-256) private JWK with an RFC 7638 thumbprint `kid`, shaped exactly as IAM's
|
|
41
|
+
* `Signer.fromPrivateJwks` loads it.
|
|
42
|
+
*/
|
|
43
|
+
const generateEs256Jwk = (): JsonWebKey & { kid: string; alg: string; use: string } => {
|
|
44
|
+
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
|
45
|
+
const jwk = privateKey.export({ format: 'jwk' })
|
|
46
|
+
// RFC 7638 thumbprint over the canonical EC members, in lexicographic order (crv, kty, x, y).
|
|
47
|
+
const thumbprint = createHash('sha256').update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })).digest('base64url')
|
|
48
|
+
return { ...jwk, kid: thumbprint, alg: 'ES256', use: 'sig' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** IAM's token-signing material: a JSON array with one ES256 private JWK, index 0 the active signer. */
|
|
52
|
+
export const generateSigningKeys = (): string => JSON.stringify([generateEs256Jwk()])
|
|
53
|
+
|
|
54
|
+
/** Everything a container installation is seeded with. Several of these are shared by more than one service. */
|
|
55
|
+
export interface InstallationSecrets {
|
|
56
|
+
/** IAM's token-signing material: a JSON array of ES256 private JWKs, index 0 active. */
|
|
57
|
+
readonly signingKeys: string
|
|
58
|
+
/** Authenticates IAM's management `/rpc/*` surface. Held by IAM, control and Operations. */
|
|
59
|
+
readonly rpcKey: string
|
|
60
|
+
/** Authenticates IAM's `/auth/mint/*` surface, which the proxy calls on the cold path. */
|
|
61
|
+
readonly proxyKey: string
|
|
62
|
+
/** The `px_` admin bearer IAM admits as a synthetic global admin. `platform init` and `admin` take it. */
|
|
63
|
+
readonly provisioningKey: string
|
|
64
|
+
/** The catalog credential control and Operations share. No `px_` prefix — it is not an IAM credential. */
|
|
65
|
+
readonly operationsSyncKey: string
|
|
66
|
+
/** The control plane's vault KEK: 32 raw bytes at base64. Its loss is unrecoverable by design. */
|
|
67
|
+
readonly vaultKey: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Roll one installation's secrets. Pure entropy in, no I/O, nothing persisted. */
|
|
71
|
+
export const generateInstallationSecrets = (): InstallationSecrets => ({
|
|
72
|
+
signingKeys: generateSigningKeys(),
|
|
73
|
+
rpcKey: randomPxKey(),
|
|
74
|
+
proxyKey: randomPxKey(),
|
|
75
|
+
provisioningKey: randomPxKey(),
|
|
76
|
+
operationsSyncKey: randomSecret(),
|
|
77
|
+
vaultKey: generateVaultKey(),
|
|
78
|
+
})
|