@fabrika/provider-zerops 0.8.2 → 0.9.0-cloudflare.2
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 +16 -14
- package/src/api/client.ts +6 -0
- package/src/api/types.ts +12 -0
- package/src/cli.ts +11 -0
- package/src/collaborators.ts +0 -14
- package/src/control.ts +33 -57
- package/src/execution.ts +113 -102
- package/src/installation/admin.ts +1 -1
- package/src/installation/deploy-options.ts +1 -2
- package/src/installation/deploy.ts +41 -75
- package/src/installation/generated/platform-console-schema.ts +3 -0
- package/src/installation/generated/platform-proxy-manifest.ts +21 -1
- package/src/installation/guide.md +9 -6
- package/src/installation/hosts.ts +1 -7
- package/src/installation/index.ts +0 -13
- package/src/installation/init-options.ts +2 -1
- package/src/installation/install-environment.ts +42 -106
- package/src/installation/install-options.ts +3 -1
- package/src/installation/install-secrets.ts +24 -68
- package/src/installation/install.ts +8 -4
- package/src/installation/sidecar.ts +5 -26
- package/src/installation/topology/setups.ts +5 -0
- package/src/installation/upgrade-options.ts +2 -2
- package/src/managed-env.ts +1 -174
- package/src/manifest.ts +11 -14
- package/src/namespace/claims.ts +56 -1
- package/src/namespace/errors.ts +1 -1
- package/src/namespace/manifest.ts +6 -19
- package/src/namespace/provisioning.ts +15 -3
- package/src/namespace/proxy-attempt.ts +43 -2
- package/src/namespace/proxy-reads.ts +12 -22
- package/src/namespace-cli.ts +152 -0
- package/src/plan.ts +2 -7
- package/src/provider.ts +128 -126
- package/src/proxy-manifest-carrier.ts +18 -0
- package/src/types.ts +0 -2
- package/types/src/api/client.d.ts +5 -0
- package/types/src/api/errors.d.ts +24 -0
- package/types/src/api/polling.d.ts +41 -0
- package/types/src/api/readers.d.ts +1115 -0
- package/types/src/api/service-env-limit.d.ts +17 -0
- package/types/src/api/service-env-response.d.ts +23 -0
- package/types/src/api/types.d.ts +687 -0
- package/types/src/api.d.ts +6 -0
- package/types/src/authoring.d.ts +9 -0
- package/types/src/cli-args.d.ts +19 -0
- package/types/src/cli.d.ts +13 -0
- package/types/src/collaborators.d.ts +16 -0
- package/types/src/compile.d.ts +91 -0
- package/types/src/control.d.ts +40 -0
- package/types/src/database-budget.d.ts +38 -0
- package/types/src/descriptor.d.ts +28 -0
- package/types/src/execution.d.ts +125 -0
- package/types/src/exposure.d.ts +47 -0
- package/types/src/index.d.ts +31 -0
- package/types/src/installation/admin-options.d.ts +24 -0
- package/types/src/installation/admin.d.ts +35 -0
- package/types/src/installation/checker-release.d.ts +12 -0
- package/types/src/installation/deploy-options.d.ts +37 -0
- package/types/src/installation/deploy.d.ts +136 -0
- package/types/src/installation/generated/platform-console-schema.d.ts +2 -0
- package/types/src/installation/generated/platform-proxy-manifest.d.ts +2 -0
- package/types/src/installation/hosts.d.ts +34 -0
- package/types/src/installation/index.d.ts +7 -0
- package/types/src/installation/init-options.d.ts +38 -0
- package/types/src/installation/init.d.ts +60 -0
- package/types/src/installation/install-collaborators.d.ts +24 -0
- package/types/src/installation/install-environment.d.ts +63 -0
- package/types/src/installation/install-options.d.ts +49 -0
- package/types/src/installation/install-project.d.ts +13 -0
- package/types/src/installation/install-provisioning.d.ts +58 -0
- package/types/src/installation/install-secrets.d.ts +17 -0
- package/types/src/installation/install-token.d.ts +10 -0
- package/types/src/installation/install.d.ts +23 -0
- package/types/src/installation/installation-source.d.ts +8 -0
- package/types/src/installation/log.d.ts +44 -0
- package/types/src/installation/machine-key.d.ts +19 -0
- package/types/src/installation/placement-options.d.ts +11 -0
- package/types/src/installation/platform-archive.d.ts +7 -0
- package/types/src/installation/platform-source.d.ts +8 -0
- package/types/src/installation/platform-upload.d.ts +3 -0
- package/types/src/installation/sidecar.d.ts +49 -0
- package/types/src/installation/source-options.d.ts +1 -0
- package/types/src/installation/topology/artifacts.d.ts +8 -0
- package/types/src/installation/topology/index.d.ts +5 -0
- package/types/src/installation/topology/installation-schemas.d.ts +2242 -0
- package/types/src/installation/topology/invariants.d.ts +23 -0
- package/types/src/installation/topology/setups.d.ts +59 -0
- package/types/src/installation/topology/topology.d.ts +153 -0
- package/types/src/installation/topology/validate.d.ts +11 -0
- package/types/src/installation/upgrade-options.d.ts +26 -0
- package/types/src/installation/upgrade-output.d.ts +6 -0
- package/types/src/installation/upgrade.d.ts +88 -0
- package/types/src/managed-env.d.ts +24 -0
- package/types/src/manifest.d.ts +204 -0
- package/types/src/namespace/claims.d.ts +16 -0
- package/types/src/namespace/codec.d.ts +334 -0
- package/types/src/namespace/constants.d.ts +25 -0
- package/types/src/namespace/errors.d.ts +15 -0
- package/types/src/namespace/manifest.d.ts +4 -0
- package/types/src/namespace/operator.d.ts +12 -0
- package/types/src/namespace/provisioning.d.ts +4 -0
- package/types/src/namespace/proxy-attempt.d.ts +39 -0
- package/types/src/namespace/proxy-reads.d.ts +22 -0
- package/types/src/namespace/proxy-secrets.d.ts +8 -0
- package/types/src/namespace/proxy-source.d.ts +74 -0
- package/types/src/namespace/topology.d.ts +9 -0
- package/types/src/namespace/types.d.ts +101 -0
- package/types/src/namespace/update.d.ts +6 -0
- package/types/src/namespace/validation.d.ts +12 -0
- package/types/src/namespace-cli.d.ts +2 -0
- package/types/src/namespace.d.ts +9 -0
- package/types/src/plan.d.ts +11 -0
- package/types/src/provider.d.ts +63 -0
- package/types/src/proxy-manifest-carrier.d.ts +24 -0
- package/types/src/schema.generated.d.ts +376 -0
- package/types/src/service-names.d.ts +4 -0
- package/types/src/service-references.d.ts +13 -0
- package/types/src/source/constants.d.ts +6 -0
- package/types/src/source/port.d.ts +60 -0
- package/types/src/source/types.d.ts +4 -0
- package/types/src/source/validation.d.ts +19 -0
- package/types/src/source.d.ts +2 -0
- package/types/src/types.d.ts +119 -0
- package/types/src/version-attempt.d.ts +8 -0
- package/src/installation/mcp.ts +0 -102
- package/src/installation/proxy-manifest.ts +0 -214
- package/src/installation/secrets.ts +0 -78
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ContractArtifact } from '@fabrika/auth-core';
|
|
2
|
+
import type { AppConfigBase } from '@fabrika/provider-contract';
|
|
3
|
+
import type { ContractProxyApp } from '@fabrika/proxy-core/manifest';
|
|
4
|
+
import type { ZeropsRuntimePackage } from './execution';
|
|
5
|
+
import type { ZeropsHealthCheck, ZeropsImportProject, ZeropsImportService, ZeropsReadinessCheck, ZeropsYamlDeploy, ZeropsYamlRun, ZeropsYamlSetup } from './schema.generated';
|
|
6
|
+
import type { ZeropsSourceRepository } from './source';
|
|
7
|
+
import type { ZeropsSourcePrivateBinding } from './source/port';
|
|
8
|
+
export interface ZeropsResourceContext {
|
|
9
|
+
env: string;
|
|
10
|
+
domain?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A probe interval or timeout, as the PLATFORM takes it — a Go duration with a unit, e.g. `30s`.
|
|
14
|
+
*
|
|
15
|
+
* The published JSON schema types all six of these as `integer`, and the platform refuses an integer:
|
|
16
|
+
* `POST /service-stack/zerops-yaml-validation` answers `yamlValidationInvalidYaml` with
|
|
17
|
+
* `cannot unmarshal !!int into time.Duration`. The account wins, so this type does. Every value is also
|
|
18
|
+
* bounded to **[10s, 1h]** — `5s` is refused with `invalid execPeriod <10s, 1h0m0s>` — which no
|
|
19
|
+
* published document states; see `docs/reference/zerops-platform.md`.
|
|
20
|
+
*/
|
|
21
|
+
export type ZeropsDuration = `${number}${'s' | 'm' | 'h'}`;
|
|
22
|
+
/** `run.healthCheck`, with the four duration fields corrected to what the platform accepts. */
|
|
23
|
+
export interface ZeropsHealthCheckSpec extends Omit<ZeropsHealthCheck, ZeropsHealthCheckDurationField> {
|
|
24
|
+
failureTimeout?: ZeropsDuration;
|
|
25
|
+
disconnectTimeout?: ZeropsDuration;
|
|
26
|
+
recoveryTimeout?: ZeropsDuration;
|
|
27
|
+
execPeriod?: ZeropsDuration;
|
|
28
|
+
}
|
|
29
|
+
type ZeropsHealthCheckDurationField = 'failureTimeout' | 'disconnectTimeout' | 'recoveryTimeout' | 'execPeriod';
|
|
30
|
+
/** `deploy.readinessCheck`, with its two duration fields corrected. */
|
|
31
|
+
export interface ZeropsReadinessCheckSpec extends Omit<ZeropsReadinessCheck, 'failureTimeout' | 'retryPeriod'> {
|
|
32
|
+
failureTimeout?: ZeropsDuration;
|
|
33
|
+
retryPeriod?: ZeropsDuration;
|
|
34
|
+
}
|
|
35
|
+
export interface ZeropsYamlRunSpec extends Omit<ZeropsYamlRun, 'healthCheck'> {
|
|
36
|
+
healthCheck?: ZeropsHealthCheckSpec;
|
|
37
|
+
}
|
|
38
|
+
export interface ZeropsYamlDeploySpec extends Omit<ZeropsYamlDeploy, 'readinessCheck'> {
|
|
39
|
+
readinessCheck?: ZeropsReadinessCheckSpec;
|
|
40
|
+
}
|
|
41
|
+
export interface ZeropsYamlSetupSpec extends Omit<ZeropsYamlSetup, 'run' | 'deploy'> {
|
|
42
|
+
run?: ZeropsYamlRunSpec;
|
|
43
|
+
deploy?: ZeropsYamlDeploySpec;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A `zerops.yaml` as fabrika AUTHORS it.
|
|
47
|
+
*
|
|
48
|
+
* Same relationship to `ZeropsYaml` that `ZeropsServiceSpec` has to `ZeropsImportService`: the generated
|
|
49
|
+
* transcription is the platform's published contract, and this is the authoring surface, which diverges
|
|
50
|
+
* from it only where a live account has proved the publication wrong.
|
|
51
|
+
*/
|
|
52
|
+
export interface ZeropsYamlSpec {
|
|
53
|
+
zerops: ZeropsYamlSetupSpec[];
|
|
54
|
+
}
|
|
55
|
+
export type ZeropsCompilerOwnedServiceField = 'envIsolation' | 'override' | 'envSecrets' | 'dotEnvSecrets' | 'mode' | 'os';
|
|
56
|
+
export type ZeropsCompilerOwnedProjectField = 'envVariables' | 'envIsolation';
|
|
57
|
+
export type ZeropsServiceSpec = Omit<ZeropsImportService, ZeropsCompilerOwnedServiceField>;
|
|
58
|
+
export type ZeropsProjectSpec = Omit<ZeropsImportProject, ZeropsCompilerOwnedProjectField>;
|
|
59
|
+
/** What the namespace proxy applies to the app: the manifest form of its compiled contract, from `contractProxyTable`. */
|
|
60
|
+
export interface ZeropsProxySpec extends Pick<ContractProxyApp, 'endpoints' | 'reservedPrefixes' | 'snapshot'> {
|
|
61
|
+
upstream: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A typed reference to the namespace-owned PostgreSQL service; it never contains its credential.
|
|
65
|
+
*
|
|
66
|
+
* The reference names its PORT, its DATABASE and its TLS MODE rather than inheriting any from the driver — see
|
|
67
|
+
* `ZEROPS_SHARED_POSTGRES_CONNECTION_STRING` in `./namespace.ts` for what the account proved about each. It is the
|
|
68
|
+
* service user's; a contract application's `FABRIKA_APP_DATABASE_URL` is generated from `hostname` instead.
|
|
69
|
+
*/
|
|
70
|
+
export interface ZeropsSharedPostgresBinding {
|
|
71
|
+
readonly resourceKey: 'service:postgres';
|
|
72
|
+
readonly hostname: 'postgres';
|
|
73
|
+
readonly connectionString: '${postgres_connectionString}/${postgres_dbName}?sslmode=disable';
|
|
74
|
+
}
|
|
75
|
+
export type ZeropsNamespaceResourceRequirement = ZeropsSharedPostgresBinding;
|
|
76
|
+
export interface ZeropsSourceTarget {
|
|
77
|
+
platform: 'zerops';
|
|
78
|
+
deployService?: string;
|
|
79
|
+
zeropsSetup?: string;
|
|
80
|
+
proxy?: ZeropsProxySpec;
|
|
81
|
+
namespaceResources?: ZeropsNamespaceResourceRequirement[];
|
|
82
|
+
/**
|
|
83
|
+
* The compiled contract of a contract application. Its presence locks the setup down (ADR-0043): the platform
|
|
84
|
+
* generates the run section and the server build, and the authored `zerops.yaml` keeps only the SPA build.
|
|
85
|
+
*/
|
|
86
|
+
contract?: ContractArtifact;
|
|
87
|
+
/** System packages for the runtime image of a contract application, from a closed list. */
|
|
88
|
+
runtimePackages?: ZeropsRuntimePackage[];
|
|
89
|
+
services: (ctx: ZeropsResourceContext) => ZeropsServiceSpec[];
|
|
90
|
+
project?: ZeropsProjectSpec;
|
|
91
|
+
}
|
|
92
|
+
export interface ZeropsAppConfig extends AppConfigBase {
|
|
93
|
+
target: ZeropsSourceTarget;
|
|
94
|
+
}
|
|
95
|
+
/** Exact, credential-free source coordinates composed for one non-dry runtime invocation. */
|
|
96
|
+
export interface ZeropsRuntimeSource {
|
|
97
|
+
runId: string;
|
|
98
|
+
repository: ZeropsSourceRepository;
|
|
99
|
+
commitSha: string;
|
|
100
|
+
/** Absent for a public repository. */
|
|
101
|
+
privateBinding?: ZeropsSourcePrivateBinding;
|
|
102
|
+
}
|
|
103
|
+
/** The run checkpoint (ADR-0044): the reserved version, and whether its build request may have been sent. */
|
|
104
|
+
export interface ZeropsRunState {
|
|
105
|
+
appVersionId: string;
|
|
106
|
+
triggered: boolean;
|
|
107
|
+
}
|
|
108
|
+
/** Ephemeral runtime coordinates. This value is encoded only after credentials are composed. */
|
|
109
|
+
export interface ZeropsRuntimeTarget {
|
|
110
|
+
projectId: string;
|
|
111
|
+
serviceId: string;
|
|
112
|
+
/** The buckets the environment's registration created, from its stored target (creation evidence, ADR-0043). */
|
|
113
|
+
createdBuckets?: readonly string[];
|
|
114
|
+
accessToken: string;
|
|
115
|
+
/** Absent only for dry runs, which perform no source or Zerops mutation. */
|
|
116
|
+
source?: ZeropsRuntimeSource;
|
|
117
|
+
apiBaseUrl?: string;
|
|
118
|
+
}
|
|
119
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ZeropsApi } from './api/types';
|
|
2
|
+
export declare const isVersionNotFound: (error: unknown) => boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Delete a reserved version whose build was never requested. Deletion is the barrier: a later upload to
|
|
5
|
+
* it leaves nothing buildable and a build request fails `appVersionNotFound` (ADR-0044). An already
|
|
6
|
+
* absent version counts as deleted. Never call this once a build may have been submitted.
|
|
7
|
+
*/
|
|
8
|
+
export declare function deleteVersion(api: Pick<ZeropsApi, 'deleteAppVersion'>, appVersionId: string, signal: AbortSignal): Promise<void>;
|
package/src/installation/mcp.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,214 +0,0 @@
|
|
|
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, so it is GENERATED into
|
|
15
|
-
// `src/generated/` and reviewed in a diff.
|
|
16
|
-
// placement the hostnames and the browser-facing scheme. `proxy-292c-8080.prg1.zerops.app` names one
|
|
17
|
-
// account's project, and a production installation binds custom domains instead, so
|
|
18
|
-
// whoever is deploying supplies these.
|
|
19
|
-
//
|
|
20
|
-
// Nothing in this file imports the packages that OWN the tables. That is the point of the split:
|
|
21
|
-
// they live in the private service packages and reach a deployed installation as DATA, through the
|
|
22
|
-
// generated artifact, so the published deploy path never depends on a package it cannot resolve.
|
|
23
|
-
|
|
24
|
-
import { type InstallationEndpointTable, installationTableDigest } from '@fabrika/auth-core'
|
|
25
|
-
import {
|
|
26
|
-
type InstallationProxyApp,
|
|
27
|
-
isApplicationEntry,
|
|
28
|
-
type OAuthProtectedResource,
|
|
29
|
-
parseProxyManifest,
|
|
30
|
-
serviceProxyTable,
|
|
31
|
-
} from '@fabrika/proxy-core/manifest'
|
|
32
|
-
|
|
33
|
-
/** The platform services an installation's proxy fronts. Each name is a service hostname in the topology. */
|
|
34
|
-
export type PlatformProxyService = 'iam' | 'control' | 'operations'
|
|
35
|
-
|
|
36
|
-
/** What one OAuth scope lets a token do: the owning app's actions, resolved by IAM at the token endpoint. */
|
|
37
|
-
export interface PlatformOAuthScopeGrant {
|
|
38
|
-
readonly scope: string
|
|
39
|
-
readonly actions: readonly string[]
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A path an app serves to OAuth clients, protected only on an installation that enables it.
|
|
44
|
-
*
|
|
45
|
-
* The resource URI is the app's public origin plus `path`, so the template holds the path and a deploy
|
|
46
|
-
* binds the origin. Copied from the owning service module by the generator, like `table`.
|
|
47
|
-
*/
|
|
48
|
-
export interface PlatformOAuthResourceTemplate {
|
|
49
|
-
readonly path: string
|
|
50
|
-
readonly scopes: readonly string[]
|
|
51
|
-
readonly scopeGrants: readonly PlatformOAuthScopeGrant[]
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* One fronted app, in the form that is the same on every installation: protected by its service contract (ADR-0022
|
|
56
|
-
* "Installation entries"), copied in by the generator and never written by hand.
|
|
57
|
-
*/
|
|
58
|
-
export interface PlatformProxyApp {
|
|
59
|
-
/** The IAM app id. It is the audience of every token the proxy mints for this app's host. */
|
|
60
|
-
readonly id: string
|
|
61
|
-
/** The topology service that answers for it. */
|
|
62
|
-
readonly service: PlatformProxyService
|
|
63
|
-
/**
|
|
64
|
-
* The proxy's public listener this app answers on.
|
|
65
|
-
*
|
|
66
|
-
* On a `zerops-subdomain` installation a port IS a hostname — Zerops mints one generated host per
|
|
67
|
-
* HTTP port — so this is the only installation-independent half of `hosts`. A custom-domain
|
|
68
|
-
* installation ignores it: every domain arrives on one listener and the `Host` header decides.
|
|
69
|
-
*/
|
|
70
|
-
readonly port: number
|
|
71
|
-
/** Where the proxy dials it over the project's private network. */
|
|
72
|
-
readonly upstream: string
|
|
73
|
-
/** The service's rows; with an `oauthResource`, the table served when the composition names an authorization server. */
|
|
74
|
-
readonly table: InstallationEndpointTable
|
|
75
|
-
/** Present only on an app that can serve OAuth clients; off unless the composition names an authorization server. */
|
|
76
|
-
readonly oauthResource?: PlatformOAuthResourceTemplate
|
|
77
|
-
/**
|
|
78
|
-
* Present exactly with `oauthResource`: the service's table when it serves no OAuth client. The service picks the
|
|
79
|
-
* same one, by the digest the deploy writes beside it (`platformServiceTableDigest`).
|
|
80
|
-
*/
|
|
81
|
-
readonly tableWithoutOAuth?: InstallationEndpointTable
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Everything about an installation's manifest that does not depend on WHICH installation it is. */
|
|
85
|
-
export interface PlatformProxyManifestTemplate {
|
|
86
|
-
readonly apps: readonly PlatformProxyApp[]
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* The template's entry for one platform service.
|
|
91
|
-
*
|
|
92
|
-
* The app IDS live in the template, so a caller that needs one — the deploy's schema reconcile needs
|
|
93
|
-
* the console's — reads it from there rather than restating a literal that could drift from the
|
|
94
|
-
* document the proxy is actually configured with.
|
|
95
|
-
*/
|
|
96
|
-
export const platformProxyAppFor = (
|
|
97
|
-
template: PlatformProxyManifestTemplate,
|
|
98
|
-
service: PlatformProxyService,
|
|
99
|
-
): PlatformProxyApp => {
|
|
100
|
-
const app = template.apps.find((candidate) => candidate.service === service)
|
|
101
|
-
if (app === undefined) {
|
|
102
|
-
throw new Error(`the platform proxy manifest template fronts no \`${service}\` service`)
|
|
103
|
-
}
|
|
104
|
-
return app
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Where one app answers in a particular composition. */
|
|
108
|
-
export interface PlatformProxyPlacement {
|
|
109
|
-
/** Public hostnames, without a port — `parseProxyManifest` refuses a `:port` suffix. */
|
|
110
|
-
readonly hosts: readonly string[]
|
|
111
|
-
/** Overrides the private dial address. The local composition runs IAM on a port of its own. */
|
|
112
|
-
readonly upstream?: string
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** One composition of the platform: a deployed installation, or the local Docker stack. */
|
|
116
|
-
export interface PlatformProxyComposition {
|
|
117
|
-
/** The scheme the BROWSER speaks to these apps — configuration, never a header (see `ProxyApp.scheme`). */
|
|
118
|
-
readonly scheme: 'http' | 'https'
|
|
119
|
-
readonly placement: Readonly<Record<PlatformProxyService, PlatformProxyPlacement>>
|
|
120
|
-
/**
|
|
121
|
-
* The public IAM origin that issues OAuth tokens. Present only when the installation enables MCP
|
|
122
|
-
* authoring; absent leaves every `oauthResource` unregistered and serves each service's table without OAuth.
|
|
123
|
-
*/
|
|
124
|
-
readonly authorizationServer?: string
|
|
125
|
-
/** The port browsers reach every app on, when it is not the scheme's default. The local composition's is 18080. */
|
|
126
|
-
readonly publicPort?: number
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* The RFC 8707 resource URI and RFC 9728 metadata URL of an app's OAuth resource, bound to the app's
|
|
131
|
-
* FIRST host: the proxy refuses a resource on a host the app does not answer on.
|
|
132
|
-
*/
|
|
133
|
-
export const platformOAuthResourceUris = (
|
|
134
|
-
composition: Pick<PlatformProxyComposition, 'scheme' | 'publicPort'>,
|
|
135
|
-
hosts: readonly string[],
|
|
136
|
-
template: PlatformOAuthResourceTemplate,
|
|
137
|
-
): { readonly resource: string; readonly resourceMetadata: string } => {
|
|
138
|
-
const host = hosts[0]
|
|
139
|
-
if (host === undefined) {
|
|
140
|
-
throw new Error('an OAuth resource needs a host to stand on')
|
|
141
|
-
}
|
|
142
|
-
const origin = composition.publicPort === undefined
|
|
143
|
-
? `${composition.scheme}://${host}`
|
|
144
|
-
: `${composition.scheme}://${host}:${composition.publicPort}`
|
|
145
|
-
return {
|
|
146
|
-
resource: `${origin}${template.path}`,
|
|
147
|
-
resourceMetadata: `${origin}/.well-known/oauth-protected-resource${template.path}`,
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const registeredResource = (
|
|
152
|
-
hosts: readonly string[],
|
|
153
|
-
template: PlatformOAuthResourceTemplate,
|
|
154
|
-
composition: PlatformProxyComposition,
|
|
155
|
-
authorizationServer: string,
|
|
156
|
-
): OAuthProtectedResource => ({
|
|
157
|
-
...platformOAuthResourceUris(composition, hosts, template),
|
|
158
|
-
authorizationServers: [authorizationServer],
|
|
159
|
-
scopesSupported: [...template.scopes],
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
/** Only an installation that names an authorization server registers the resource and serves its OAuth rows. */
|
|
163
|
-
const resolvedApp = (app: PlatformProxyApp, composition: PlatformProxyComposition): InstallationProxyApp => {
|
|
164
|
-
const placement = composition.placement[app.service]
|
|
165
|
-
const hosts = [...placement.hosts]
|
|
166
|
-
const registered = app.oauthResource === undefined || composition.authorizationServer === undefined
|
|
167
|
-
? undefined
|
|
168
|
-
: registeredResource(hosts, app.oauthResource, composition, composition.authorizationServer)
|
|
169
|
-
const table = registered === undefined ? (app.tableWithoutOAuth ?? app.table) : app.table
|
|
170
|
-
return {
|
|
171
|
-
id: app.id,
|
|
172
|
-
hosts,
|
|
173
|
-
upstream: placement.upstream ?? app.upstream,
|
|
174
|
-
scheme: composition.scheme,
|
|
175
|
-
...serviceProxyTable(table, { oauth: registered !== undefined }),
|
|
176
|
-
...(registered === undefined ? {} : { oauthResources: [registered] }),
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Bind a template to one composition and get the document the proxy actually parses.
|
|
182
|
-
*
|
|
183
|
-
* The result is returned THROUGH `parseProxyManifest`, so a duplicate id, a repeated host or a `:port`
|
|
184
|
-
* suffix is refused right here instead of at a deployed proxy's next build.
|
|
185
|
-
*/
|
|
186
|
-
export const resolvePlatformProxyManifest = (
|
|
187
|
-
template: PlatformProxyManifestTemplate,
|
|
188
|
-
composition: PlatformProxyComposition,
|
|
189
|
-
): PlatformProxyManifest => {
|
|
190
|
-
const manifest = parseProxyManifest({ apps: template.apps.map((app) => resolvedApp(app, composition)) })
|
|
191
|
-
// Every platform service is an installation entry, never an application.
|
|
192
|
-
const services = manifest?.apps.flatMap((app) => isApplicationEntry(app) ? [] : [app]) ?? []
|
|
193
|
-
if (manifest === null || services.length !== manifest.apps.length) {
|
|
194
|
-
throw new Error('the resolved platform proxy manifest is not one the proxy would accept')
|
|
195
|
-
}
|
|
196
|
-
return { apps: services }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** The platform proxy's manifest: one installation entry per platform service. */
|
|
200
|
-
export interface PlatformProxyManifest {
|
|
201
|
-
apps: InstallationProxyApp[]
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* `FABRIKA_SERVICE_TABLE_DIGEST` for a service: the digest of the table this manifest publishes for it, which the
|
|
206
|
-
* service compares with its own and refuses to start under another.
|
|
207
|
-
*/
|
|
208
|
-
export const platformServiceTableDigest = (manifest: PlatformProxyManifest, appId: string): string => {
|
|
209
|
-
const entry = manifest.apps.find((app) => app.id === appId)
|
|
210
|
-
if (entry === undefined) {
|
|
211
|
-
throw new Error(`the platform proxy manifest fronts no \`${appId}\``)
|
|
212
|
-
}
|
|
213
|
-
return installationTableDigest(entry)
|
|
214
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// The six values `fabrika platform install --provider=zerops` GENERATES, and their shapes.
|
|
2
|
-
//
|
|
3
|
-
// Every one of them is written to a Zerops service as a SENSITIVE variable, which Zerops does not hand
|
|
4
|
-
// back, and to the operator's local secrets file (`install-secrets.ts`) before that. None is printed.
|
|
5
|
-
//
|
|
6
|
-
// The shapes are not free choices. Each one is what the consuming code already parses:
|
|
7
|
-
//
|
|
8
|
-
// • `FABRIKA_IAM_SIGNING_KEYS` is a JSON array of PRIVATE JWKs, index 0 the active signer
|
|
9
|
-
// (`@fabrika/iam`'s `Signer.fromPrivateJwks`). ES256 / P-256, RFC 7638 thumbprint `kid`.
|
|
10
|
-
// • A `px_` key is IAM's own credential spelling, and every shared secret must be at least 32
|
|
11
|
-
// characters or the service refuses to boot — 32 random bytes at base64url is 43.
|
|
12
|
-
// • The vault KEK is 32 RAW BYTES at base64, not base64url: `@fabrika/control` imports it as an
|
|
13
|
-
// AES key, and base64url is a different alphabet.
|
|
14
|
-
//
|
|
15
|
-
// Nothing here re-reads an existing value. `install` is a bring-up; rotating a live installation's
|
|
16
|
-
// signing keys or vault KEK is not something a bring-up may do by accident, so the command refuses to
|
|
17
|
-
// run against a project that already carries these keys rather than generating over them.
|
|
18
|
-
|
|
19
|
-
import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto'
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* A 256-bit secret in base64url, never starting with `-` or `_`.
|
|
23
|
-
*
|
|
24
|
-
* The re-roll is copied from `@fabrika/local-stack`'s `prepare.ts` and it is not superstition: a
|
|
25
|
-
* credential beginning with `-` reads as a FLAG to anything that later takes it on a command line, no
|
|
26
|
-
* matter how it is quoted. One rolled password beginning with `-` made `mc alias set` print its usage
|
|
27
|
-
* and loop forever. It costs nothing and removes a bug that appears about one install in sixteen.
|
|
28
|
-
*/
|
|
29
|
-
const randomSecret = (): string => {
|
|
30
|
-
for (;;) {
|
|
31
|
-
const value = randomBytes(32).toString('base64url')
|
|
32
|
-
if (!value.startsWith('-') && !value.startsWith('_')) {
|
|
33
|
-
return value
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** The `px_` prefix IAM's own credentials carry, so a key looks like what it is wherever it turns up. */
|
|
39
|
-
const randomPxKey = (): string => `px_${randomSecret()}`
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* One ES256 (EC P-256) private JWK with an RFC 7638 thumbprint `kid`, shaped exactly as IAM's
|
|
43
|
-
* `Signer.fromPrivateJwks` loads it.
|
|
44
|
-
*/
|
|
45
|
-
const generateEs256Jwk = (): JsonWebKey & { kid: string; alg: string; use: string } => {
|
|
46
|
-
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
|
47
|
-
const jwk = privateKey.export({ format: 'jwk' })
|
|
48
|
-
// RFC 7638 thumbprint over the canonical EC members, in lexicographic order (crv, kty, x, y).
|
|
49
|
-
const thumbprint = createHash('sha256').update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })).digest('base64url')
|
|
50
|
-
return { ...jwk, kid: thumbprint, alg: 'ES256', use: 'sig' }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Everything one installation is seeded with. Several of these are shared by more than one service. */
|
|
54
|
-
export interface InstallationSecrets {
|
|
55
|
-
/** IAM's token-signing material: a JSON array of ES256 private JWKs, index 0 active. */
|
|
56
|
-
readonly signingKeys: string
|
|
57
|
-
/** Authenticates IAM's management `/rpc/*` surface. Held by IAM, control and Operations. */
|
|
58
|
-
readonly rpcKey: string
|
|
59
|
-
/** Authenticates IAM's `/auth/mint/*` surface, which the proxy calls on the cold path. */
|
|
60
|
-
readonly proxyKey: string
|
|
61
|
-
/** The `px_` admin bearer IAM admits as a synthetic global admin. `platform init` and `admin` take it. */
|
|
62
|
-
readonly provisioningKey: string
|
|
63
|
-
/** The catalog credential control and Operations share. No `px_` prefix — it is not an IAM credential. */
|
|
64
|
-
readonly operationsSyncKey: string
|
|
65
|
-
/** The control plane's vault KEK: 32 raw bytes at base64. Its loss is unrecoverable by design. */
|
|
66
|
-
readonly vaultKey: string
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Roll one installation's secrets. Pure entropy in, no I/O, nothing persisted. */
|
|
70
|
-
export const generateInstallationSecrets = (): InstallationSecrets => ({
|
|
71
|
-
signingKeys: JSON.stringify([generateEs256Jwk()]),
|
|
72
|
-
rpcKey: randomPxKey(),
|
|
73
|
-
proxyKey: randomPxKey(),
|
|
74
|
-
provisioningKey: randomPxKey(),
|
|
75
|
-
operationsSyncKey: randomSecret(),
|
|
76
|
-
// base64, NOT base64url: the vault imports these bytes as an AES key.
|
|
77
|
-
vaultKey: randomBytes(32).toString('base64'),
|
|
78
|
-
})
|