@sponson/sim 0.4.0 → 0.5.0
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 +5 -0
- package/dist/auth.d.ts +60 -0
- package/dist/auth.js +195 -0
- package/dist/auth.js.map +1 -0
- package/dist/bin.js +9 -14
- package/dist/bin.js.map +1 -1
- package/dist/chaos.d.ts +4 -0
- package/dist/chaos.js +2 -0
- package/dist/chaos.js.map +1 -1
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +99 -0
- package/dist/cli.js.map +1 -0
- package/dist/demo.d.ts +39 -0
- package/dist/demo.js +168 -0
- package/dist/demo.js.map +1 -0
- package/dist/graphql.d.ts +22 -0
- package/dist/graphql.js +36 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +43 -4
- package/dist/provider.js +12 -6
- package/dist/provider.js.map +1 -1
- package/dist/routes/algolia.d.ts +37 -0
- package/dist/routes/algolia.js +83 -0
- package/dist/routes/algolia.js.map +1 -0
- package/dist/routes/aws.d.ts +43 -0
- package/dist/routes/aws.js +211 -0
- package/dist/routes/aws.js.map +1 -0
- package/dist/routes/cloudflare.d.ts +80 -0
- package/dist/routes/cloudflare.js +176 -0
- package/dist/routes/cloudflare.js.map +1 -0
- package/dist/routes/digitalocean.d.ts +58 -0
- package/dist/routes/digitalocean.js +158 -0
- package/dist/routes/digitalocean.js.map +1 -0
- package/dist/routes/fly.d.ts +22 -0
- package/dist/routes/fly.js +113 -0
- package/dist/routes/fly.js.map +1 -0
- package/dist/routes/launchdarkly.d.ts +98 -0
- package/dist/routes/launchdarkly.js +213 -0
- package/dist/routes/launchdarkly.js.map +1 -0
- package/dist/routes/netlify.d.ts +126 -0
- package/dist/routes/netlify.js +347 -0
- package/dist/routes/netlify.js.map +1 -0
- package/dist/routes/planetscale.d.ts +95 -0
- package/dist/routes/planetscale.js +234 -0
- package/dist/routes/planetscale.js.map +1 -0
- package/dist/routes/railway.d.ts +24 -0
- package/dist/routes/railway.js +112 -0
- package/dist/routes/railway.js.map +1 -0
- package/dist/routes/rest.d.ts +138 -0
- package/dist/routes/rest.js +462 -0
- package/dist/routes/rest.js.map +1 -0
- package/dist/routes/supabase.d.ts +103 -0
- package/dist/routes/supabase.js +234 -0
- package/dist/routes/supabase.js.map +1 -0
- package/dist/routes/xata.d.ts +57 -0
- package/dist/routes/xata.js +115 -0
- package/dist/routes/xata.js.map +1 -0
- package/dist/server.js +71 -12
- package/dist/server.js.map +1 -1
- package/dist/state.d.ts +12 -0
- package/dist/state.js +13 -1
- package/dist/state.js.map +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulated DigitalOcean App Platform: apps built from an app spec, each create and update starting a deployment.
|
|
3
|
+
*
|
|
4
|
+
* Assumptions about the real API this fake encodes, checked against the official OpenAPI document
|
|
5
|
+
* (https://github.com/digitalocean/openapi, `specification/resources/apps`) and the docs unless marked unverified.
|
|
6
|
+
* The critical ones are pinned in scenarios/contract.test.ts (`assumption DO…`).
|
|
7
|
+
* DO1. The API is `https://api.digitalocean.com`, authenticated as `Authorization: Bearer <token>` (scopes
|
|
8
|
+
* app:create/read/update/delete); doctl reads the token from `DIGITALOCEAN_ACCESS_TOKEN`. (verified:
|
|
9
|
+
* https://docs.digitalocean.com/reference/api/digitalocean/, doctl README)
|
|
10
|
+
* DO2. `POST /v2/apps` with `{ spec }` creates an app and answers `{ app: { id, spec, pending_deployment, … } }`;
|
|
11
|
+
* `GET /v2/apps/{id}` answers `{ app }` (404 when gone); `GET /v2/apps?page=&per_page=` lists
|
|
12
|
+
* `{ apps, links: { pages: { next } }, meta }`, with no name filter. (verified: apps_create.yml, apps_get.yml,
|
|
13
|
+
* apps_list.yml)
|
|
14
|
+
* DO3. An app's `spec.name` is unique in the account (`^[a-z][a-z0-9-]{0,30}[a-z0-9]$`); the status of a
|
|
15
|
+
* duplicate create is not documented (unverified: the sim answers 409). (verified: app_spec.yml)
|
|
16
|
+
* DO4. `PUT /v2/apps/{id}` with `{ spec }` replaces the whole spec and starts a deployment. (verified:
|
|
17
|
+
* apps_update.yml; "starts a deployment" inferred from `pending_deployment` being set for UpdateApp)
|
|
18
|
+
* DO5. `pending_deployment` names the deployment a create or update started; `GET
|
|
19
|
+
* /v2/apps/{app_id}/deployments/{deployment_id}` answers `{ deployment: { id, phase } }`, the phase one of
|
|
20
|
+
* PENDING_BUILD, BUILDING, PENDING_DEPLOY, DEPLOYING, ACTIVE, SUPERSEDED, ERROR, CANCELED. (verified:
|
|
21
|
+
* app.yml, apps_get_deployment.yml, apps_deployment_response.yml, apps_deployment_phase.yml)
|
|
22
|
+
* DO6. The platform adds defaults to the spec it stores (a region, instance counts); it keeps every field the
|
|
23
|
+
* client sent, lists in the order sent. (unverified: the sim adds `region` and `instance_count`)
|
|
24
|
+
* DO7. A `type: SECRET` variable's value is encrypted on first submission and read back as `EV[…]`.
|
|
25
|
+
* (verified: app_variable_definition.yml, "encrypted on first submission"; the read-back form unverified)
|
|
26
|
+
* DO8. `DELETE /v2/apps/{id}` deletes the app and its deployments (200 `{ id }`; 404 when gone). (verified:
|
|
27
|
+
* apps_delete.yml)
|
|
28
|
+
*/
|
|
29
|
+
import { Reply, route, router } from "../provider.js";
|
|
30
|
+
/** The simulated App Platform API, registered in `PROVIDERS` (packages/sim/src/state.ts) and served under `/digitalocean`. */
|
|
31
|
+
export const digitaloceanSim = {
|
|
32
|
+
env: { token: "DIGITALOCEAN_ACCESS_TOKEN", url: "DIGITALOCEAN_API_URL", testToken: "tok_digitalocean" },
|
|
33
|
+
defaultSeed: { apps: [] },
|
|
34
|
+
reset(core, seed) {
|
|
35
|
+
const apps = (seed?.apps ?? []).map((spec) => ({ id: core.nextId("app_"), spec: stored(core, spec), liveUrl: `https://${String(spec.name)}.ondigitalocean.app`, deployments: {}, pending: null, createdBy: "sim" }));
|
|
36
|
+
return { apps, deployPolls: seed?.deployPolls ?? 1, failDeployments: seed?.failDeployments ?? false };
|
|
37
|
+
},
|
|
38
|
+
/**
|
|
39
|
+
* `app.<name>`: "delete"; `app.<name>.env.<KEY>`: a new value for that app-level variable — what a human in the
|
|
40
|
+
* control panel would do.
|
|
41
|
+
*/
|
|
42
|
+
drift(_core, state, { key, rest, value, only }) {
|
|
43
|
+
if (only !== undefined || !rest.startsWith("app."))
|
|
44
|
+
return false;
|
|
45
|
+
const [name, kind, envKey] = rest.slice("app.".length).split(".");
|
|
46
|
+
const app = state.apps.find((a) => a.spec.name === name);
|
|
47
|
+
if (!app)
|
|
48
|
+
throw new Error(`drift ${key}: no app ${String(name)}`);
|
|
49
|
+
if (kind === undefined && value === "delete")
|
|
50
|
+
state.apps = state.apps.filter((a) => a !== app);
|
|
51
|
+
else if (kind === "env" && envKey) {
|
|
52
|
+
const env = (app.spec.envs ?? []).find((e) => e.key === envKey);
|
|
53
|
+
if (!env)
|
|
54
|
+
throw new Error(`drift ${key}: no variable ${envKey}`);
|
|
55
|
+
env.value = value;
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
throw new Error(`drift ${key}: use app.<name> = delete, or app.<name>.env.<KEY> = <value>`);
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
routes(core, state, req) {
|
|
62
|
+
return routes(core, state, req);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
/** The spec as stored: defaults added (DO6), SECRET values encrypted (DO7). */
|
|
66
|
+
function stored(core, spec) {
|
|
67
|
+
const s = structuredClone(spec);
|
|
68
|
+
s.region ??= "nyc";
|
|
69
|
+
for (const svc of s.services ?? [])
|
|
70
|
+
svc.instance_count ??= 1;
|
|
71
|
+
const encrypt = (envs) => {
|
|
72
|
+
for (const e of envs ?? [])
|
|
73
|
+
if (e.type === "SECRET" && typeof e.value === "string" && !e.value.startsWith("EV["))
|
|
74
|
+
e.value = `EV[1:${core.nextId("")}:sim]`;
|
|
75
|
+
};
|
|
76
|
+
encrypt(s.envs);
|
|
77
|
+
for (const svc of s.services ?? [])
|
|
78
|
+
encrypt(svc.envs);
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
function deploy(core, state, app) {
|
|
82
|
+
const id = core.nextId("dep_");
|
|
83
|
+
app.deployments[id] = { phase: "PENDING_BUILD", polls: state.deployPolls };
|
|
84
|
+
app.pending = id;
|
|
85
|
+
}
|
|
86
|
+
function publicApp(a) {
|
|
87
|
+
return { id: a.id, spec: a.spec, ...(a.liveUrl ? { live_url: a.liveUrl } : {}), ...(a.pending ? { pending_deployment: { id: a.pending } } : {}) };
|
|
88
|
+
}
|
|
89
|
+
const NAME = /^[a-z][a-z0-9-]{0,30}[a-z0-9]$/;
|
|
90
|
+
const notFound = () => new Reply(404, { id: "not_found", message: "The resource you were accessing could not be found." });
|
|
91
|
+
function specOf(body) {
|
|
92
|
+
const spec = body?.spec;
|
|
93
|
+
if (typeof spec !== "object" || spec === null || Array.isArray(spec) || typeof spec.name !== "string" || !NAME.test(spec.name)) {
|
|
94
|
+
return new Reply(422, { id: "unprocessable_entity", message: "spec.name is invalid" });
|
|
95
|
+
}
|
|
96
|
+
return spec;
|
|
97
|
+
}
|
|
98
|
+
const routes = router([
|
|
99
|
+
route("GET", "/v2/apps", ({ state, url }) => {
|
|
100
|
+
const per = Math.max(1, Number(url.searchParams.get("per_page") ?? 20));
|
|
101
|
+
const page = Math.max(1, Number(url.searchParams.get("page") ?? 1));
|
|
102
|
+
const slice = state.apps.slice((page - 1) * per, page * per);
|
|
103
|
+
const next = page * per < state.apps.length ? { next: `https://api.digitalocean.com/v2/apps?page=${page + 1}&per_page=${per}` } : {};
|
|
104
|
+
return new Reply(200, { apps: slice.map(publicApp), links: { pages: next }, meta: { total: state.apps.length } });
|
|
105
|
+
}),
|
|
106
|
+
route("POST", "/v2/apps", ({ core, state, body }) => {
|
|
107
|
+
const spec = specOf(body);
|
|
108
|
+
if (spec instanceof Reply)
|
|
109
|
+
return spec;
|
|
110
|
+
if (state.apps.some((a) => a.spec.name === spec.name))
|
|
111
|
+
return new Reply(409, { id: "conflict", message: "an app with this name already exists" });
|
|
112
|
+
const app = { id: core.nextId("app_"), spec: stored(core, spec), liveUrl: null, deployments: {}, pending: null, createdBy: "api" };
|
|
113
|
+
deploy(core, state, app);
|
|
114
|
+
state.apps.push(app);
|
|
115
|
+
return new Reply(200, { app: publicApp(app) });
|
|
116
|
+
}),
|
|
117
|
+
route("GET", "/v2/apps/:id", ({ state, params }) => {
|
|
118
|
+
const app = state.apps.find((a) => a.id === params.id);
|
|
119
|
+
return app ? new Reply(200, { app: publicApp(app) }) : notFound();
|
|
120
|
+
}),
|
|
121
|
+
route("PUT", "/v2/apps/:id", ({ core, state, params, body }) => {
|
|
122
|
+
const app = state.apps.find((a) => a.id === params.id);
|
|
123
|
+
if (!app)
|
|
124
|
+
return notFound();
|
|
125
|
+
const spec = specOf(body);
|
|
126
|
+
if (spec instanceof Reply)
|
|
127
|
+
return spec;
|
|
128
|
+
app.spec = stored(core, spec);
|
|
129
|
+
deploy(core, state, app);
|
|
130
|
+
return new Reply(200, { app: publicApp(app) });
|
|
131
|
+
}),
|
|
132
|
+
route("GET", "/v2/apps/:id/deployments/:deployment", ({ state, params }) => {
|
|
133
|
+
const app = state.apps.find((a) => a.id === params.id);
|
|
134
|
+
const d = app?.deployments[params.deployment];
|
|
135
|
+
if (!app || !d)
|
|
136
|
+
return notFound();
|
|
137
|
+
if (d.polls > 0) {
|
|
138
|
+
d.polls--;
|
|
139
|
+
d.phase = "BUILDING";
|
|
140
|
+
}
|
|
141
|
+
else if (d.phase !== "ACTIVE" && d.phase !== "ERROR") {
|
|
142
|
+
d.phase = state.failDeployments ? "ERROR" : "ACTIVE";
|
|
143
|
+
if (d.phase === "ACTIVE") {
|
|
144
|
+
app.liveUrl = `https://${String(app.spec.name)}-${app.id.slice(4)}.ondigitalocean.app`;
|
|
145
|
+
app.pending = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return new Reply(200, { deployment: { id: params.deployment, phase: d.phase } });
|
|
149
|
+
}),
|
|
150
|
+
route("DELETE", "/v2/apps/:id", ({ state, params }) => {
|
|
151
|
+
const app = state.apps.find((a) => a.id === params.id);
|
|
152
|
+
if (!app)
|
|
153
|
+
return notFound();
|
|
154
|
+
state.apps = state.apps.filter((a) => a !== app);
|
|
155
|
+
return new Reply(200, { id: app.id });
|
|
156
|
+
}),
|
|
157
|
+
], notFound);
|
|
158
|
+
//# sourceMappingURL=digitalocean.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"digitalocean.js","sourceRoot":"","sources":["../../src/routes/digitalocean.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ProviderSim } from "../provider.js";
|
|
2
|
+
/** One secret as stored: the value (never answered), its digest and when it was set. */
|
|
3
|
+
export interface FlySecret {
|
|
4
|
+
value: string;
|
|
5
|
+
digest: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
}
|
|
8
|
+
/** Simulated Fly.io state: apps by name, with their secrets and release counter. */
|
|
9
|
+
export interface FlyState {
|
|
10
|
+
apps: Record<string, {
|
|
11
|
+
secrets: Record<string, FlySecret>;
|
|
12
|
+
releases: number;
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
/** Initial Fly.io state: apps by name, with secret values. */
|
|
16
|
+
export interface FlySeed {
|
|
17
|
+
apps?: Record<string, {
|
|
18
|
+
secrets?: Record<string, string>;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
/** Simulated Fly.io GraphQL API; see the assumptions at the top of this file. */
|
|
22
|
+
export declare const flySim: ProviderSim<FlyState, FlySeed>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulated Fly.io GraphQL API (`POST https://api.fly.io/graphql`), the subset that sets an app's secrets: enough
|
|
3
|
+
* to test the generic `http` adapter's GraphQL requests against Fly's real operation shapes.
|
|
4
|
+
*
|
|
5
|
+
* Assumptions (docs/api-verification.md, "Railway and Fly.io through the generic adapter"):
|
|
6
|
+
* F1. `Authorization: Bearer <token>`, or `FlyV1 <macaroon>` for the tokens `fly tokens create` prints (sent as
|
|
7
|
+
* they are). Verified: superfly/fly-go tokens/tokens.go.
|
|
8
|
+
* F2. `app(name: String!) { secrets { name digest createdAt } }` lists an app's secrets; values are never
|
|
9
|
+
* returned. Verified: fly-go resource_secrets.go and schema.graphql.
|
|
10
|
+
* F3. `setSecrets(input: SetSecretsInput!)` (appId: the app name, secrets: [{ key, value }], replaceAll?) and
|
|
11
|
+
* `unsetSecrets(input: UnsetSecretsInput!)` (appId, keys) answer `{ app, release }`; each makes a new
|
|
12
|
+
* release. Verified: fly-go schema.graphql. Unsetting a key that is not set is not an error here: unverified.
|
|
13
|
+
* F4. Failures are HTTP 200 with `errors[]`; an unknown app is "Could not find App". Unverified wording.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { graphqlData, graphqlError, graphqlOperation, isMutation } from "../graphql.js";
|
|
17
|
+
import { Reply, route, router } from "../provider.js";
|
|
18
|
+
/** Simulated Fly.io GraphQL API; see the assumptions at the top of this file. */
|
|
19
|
+
export const flySim = {
|
|
20
|
+
env: { token: "FLY_API_TOKEN", url: "FLY_API_URL", testToken: "tok_fly" },
|
|
21
|
+
defaultSeed: { apps: { "demo-pr-42": {} } },
|
|
22
|
+
reset(_core, seed) {
|
|
23
|
+
const apps = {};
|
|
24
|
+
for (const [name, a] of Object.entries(seed?.apps ?? {})) {
|
|
25
|
+
apps[name] = { secrets: Object.fromEntries(Object.entries(a.secrets ?? {}).map(([k, v]) => [k, secret(v)])), releases: 0 };
|
|
26
|
+
}
|
|
27
|
+
return { apps };
|
|
28
|
+
},
|
|
29
|
+
/** `secrets.<app>.<NAME>`: a new value, or "delete". */
|
|
30
|
+
drift(_core, state, { key, rest, value, only }) {
|
|
31
|
+
if (only !== undefined)
|
|
32
|
+
return false;
|
|
33
|
+
const [kind, app, name] = rest.split(".");
|
|
34
|
+
if (kind !== "secrets" || !app || !name)
|
|
35
|
+
return false;
|
|
36
|
+
const a = state.apps[app];
|
|
37
|
+
if (!a)
|
|
38
|
+
throw new Error(`drift ${key}: no app ${app}`);
|
|
39
|
+
if (value === "delete")
|
|
40
|
+
delete a.secrets[name];
|
|
41
|
+
else
|
|
42
|
+
a.secrets[name] = secret(value);
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
authorized(headers) {
|
|
46
|
+
return /^(Bearer|FlyV1)\s+\S+/.test(headers.authorization ?? "");
|
|
47
|
+
},
|
|
48
|
+
isWrite(req) {
|
|
49
|
+
return isMutation(req.body);
|
|
50
|
+
},
|
|
51
|
+
routes(core, state, req) {
|
|
52
|
+
return routes(core, state, req);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
function secret(value) {
|
|
56
|
+
return { value, digest: createHash("sha256").update(value).digest("hex").slice(0, 16), createdAt: "2026-10-11T00:00:00Z" };
|
|
57
|
+
}
|
|
58
|
+
function input(v) {
|
|
59
|
+
return typeof v.input === "object" && v.input !== null ? v.input : {};
|
|
60
|
+
}
|
|
61
|
+
function appOf(state, name) {
|
|
62
|
+
const app = typeof name === "string" ? state.apps[name] : undefined;
|
|
63
|
+
return app ?? graphqlError("Could not find App", "NOT_FOUND");
|
|
64
|
+
}
|
|
65
|
+
function release(app, name, field) {
|
|
66
|
+
app.releases += 1;
|
|
67
|
+
return graphqlData({ [field]: { app: { name }, release: { id: `rel_${app.releases}`, version: app.releases } } });
|
|
68
|
+
}
|
|
69
|
+
const RESOLVERS = {
|
|
70
|
+
app(state, v) {
|
|
71
|
+
const name = v.appName ?? v.name;
|
|
72
|
+
const app = appOf(state, name);
|
|
73
|
+
if (app instanceof Reply)
|
|
74
|
+
return app;
|
|
75
|
+
return graphqlData({ app: { name, secrets: Object.entries(app.secrets).map(([n, s]) => ({ name: n, digest: s.digest, createdAt: s.createdAt })) } });
|
|
76
|
+
},
|
|
77
|
+
setSecrets(state, v) {
|
|
78
|
+
const i = input(v);
|
|
79
|
+
const app = appOf(state, i.appId);
|
|
80
|
+
if (app instanceof Reply)
|
|
81
|
+
return app;
|
|
82
|
+
const list = Array.isArray(i.secrets) ? i.secrets : [];
|
|
83
|
+
if (list.length === 0 || !list.every((s) => typeof s.key === "string" && typeof s.value === "string"))
|
|
84
|
+
return graphqlError("secrets must be a non-empty list of { key, value }", "BAD_USER_INPUT");
|
|
85
|
+
if (i.replaceAll === true)
|
|
86
|
+
app.secrets = {};
|
|
87
|
+
for (const s of list)
|
|
88
|
+
app.secrets[s.key] = secret(s.value);
|
|
89
|
+
return release(app, i.appId, "setSecrets");
|
|
90
|
+
},
|
|
91
|
+
unsetSecrets(state, v) {
|
|
92
|
+
const i = input(v);
|
|
93
|
+
const app = appOf(state, i.appId);
|
|
94
|
+
if (app instanceof Reply)
|
|
95
|
+
return app;
|
|
96
|
+
const keys = Array.isArray(i.keys) ? i.keys.filter((k) => typeof k === "string") : [];
|
|
97
|
+
if (keys.length === 0)
|
|
98
|
+
return graphqlError("keys must be a non-empty list", "BAD_USER_INPUT");
|
|
99
|
+
for (const k of keys)
|
|
100
|
+
delete app.secrets[k];
|
|
101
|
+
return release(app, i.appId, "unsetSecrets");
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const routes = router([
|
|
105
|
+
route("POST", "/", ({ state, body }) => {
|
|
106
|
+
const op = graphqlOperation(body);
|
|
107
|
+
if (op instanceof Reply)
|
|
108
|
+
return op;
|
|
109
|
+
const resolve = RESOLVERS[op.root];
|
|
110
|
+
return resolve ? resolve(state, op.variables) : graphqlError(`Cannot query field "${op.root}" on type "${op.kind === "mutation" ? "Mutations" : "Queries"}"`, "GRAPHQL_VALIDATION_FAILED");
|
|
111
|
+
}),
|
|
112
|
+
], () => new Reply(404, { error: "not found" }));
|
|
113
|
+
//# sourceMappingURL=fly.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fly.js","sourceRoot":"","sources":["../../src/routes/fly.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulated LaunchDarkly: feature flags per project, with each environment's individual targets (a context key of a
|
|
3
|
+
* context kind served one variation).
|
|
4
|
+
*
|
|
5
|
+
* Assumptions about the real API that this fake encodes, each marked with how far it is checked. "Verified" means
|
|
6
|
+
* against LaunchDarkly's published REST API reference (fetched 2026-10-11): the API overview
|
|
7
|
+
* https://launchdarkly.com/docs/api, "Get feature flag" https://launchdarkly.com/docs/api/feature-flags/get-feature-flag
|
|
8
|
+
* and "Update feature flag" https://launchdarkly.com/docs/api/feature-flags/patch-feature-flag; see
|
|
9
|
+
* docs/api-verification.md:
|
|
10
|
+
* LD1. The access token is the whole `Authorization` value, with no `Bearer` prefix (verified: "The value of the
|
|
11
|
+
* `Authorization` header must be your access token"). The base URL is https://app.launchdarkly.com/api/v2
|
|
12
|
+
* (verified from the OpenAPI document's location; federal and EU instances use other hosts). The sim
|
|
13
|
+
* refuses a `Bearer` header with 401.
|
|
14
|
+
* LD2. `GET /flags/{projectKey}/{featureFlagKey}?env=<key>` answers the flag with `variations[]` (`_id`, `value`,
|
|
15
|
+
* optional `name`) and `environments.<key>` restricted to that environment (verified). An unknown project or
|
|
16
|
+
* flag is 404 (verified: "Invalid resource identifier"). An environment the project lacks is absent from
|
|
17
|
+
* `environments` (unverified: whether LaunchDarkly answers 404 or an empty map instead; the adapter
|
|
18
|
+
* reports either as not found).
|
|
19
|
+
* LD3. An environment's individual targets are `targets[]` (context kind `user`) and `contextTargets[]` (every
|
|
20
|
+
* other kind), items `{ contextKind, values, variation }` where `variation` is an index into `variations`
|
|
21
|
+
* (verified). `contextTargets` also lists, for each variation with user targets, a placeholder
|
|
22
|
+
* `{ contextKind: "user", values: [] }` (unverified, observed behaviour reported by SDK users; the adapter
|
|
23
|
+
* reads both arrays and ignores empty entries, so it does not depend on it).
|
|
24
|
+
* LD4. `PATCH /flags/{projectKey}/{featureFlagKey}` with `Content-Type: application/json;
|
|
25
|
+
* domain-model=launchdarkly.semanticpatch` and `{ environmentKey, instructions, comment? }` applies the
|
|
26
|
+
* instructions; without that media type the body is read as a JSON patch and a semantic patch is 400
|
|
27
|
+
* (verified). It answers 200 with the whole flag (verified). Instructions are all or nothing: one invalid
|
|
28
|
+
* instruction and nothing changes (verified).
|
|
29
|
+
* LD5. `addTargets { contextKind, values, variationId }` (`contextKind` defaults to `user`) is refused (400)
|
|
30
|
+
* when it would make a context key targeted by two variations (verified that it is an error; the status
|
|
31
|
+
* is unverified). Adding a key already targeted by the same variation changes nothing and succeeds
|
|
32
|
+
* (unverified: the docs say only that a semantic patch updates the flag "only where needed"; pinned by the
|
|
33
|
+
* contract suite). `removeTargets` with the same fields does nothing for a key not targeted (verified).
|
|
34
|
+
* LD6. An environment that requires approvals answers 405 to a flag change (verified); a concurrent change
|
|
35
|
+
* answers 409, to be retried (verified). The error body is `{ code, message }` (verified).
|
|
36
|
+
* LD7. The PATCH takes effect at once: the next GET shows it (unverified; pinned by the contract suite).
|
|
37
|
+
*/
|
|
38
|
+
import { type CreatedBy, type ProviderSim } from "../provider.js";
|
|
39
|
+
/** The media type of a semantic patch (LD4). */
|
|
40
|
+
export declare const LAUNCHDARKLY_SEMANTIC_PATCH = "application/json; domain-model=launchdarkly.semanticpatch";
|
|
41
|
+
/** One variation of a flag. */
|
|
42
|
+
export interface LaunchdarklyVariation {
|
|
43
|
+
_id: string;
|
|
44
|
+
value: unknown;
|
|
45
|
+
name?: string;
|
|
46
|
+
}
|
|
47
|
+
/** One individual target: a context key of a kind, served the variation at `variation` (an index). */
|
|
48
|
+
export interface LaunchdarklyTarget {
|
|
49
|
+
contextKind: string;
|
|
50
|
+
key: string;
|
|
51
|
+
variation: number;
|
|
52
|
+
createdBy: CreatedBy;
|
|
53
|
+
}
|
|
54
|
+
/** A flag's configuration in one environment, as far as the sim models it. */
|
|
55
|
+
export interface LaunchdarklyFlagEnv {
|
|
56
|
+
on: boolean;
|
|
57
|
+
version: number;
|
|
58
|
+
targets: LaunchdarklyTarget[];
|
|
59
|
+
}
|
|
60
|
+
/** One feature flag. */
|
|
61
|
+
export interface LaunchdarklyFlag {
|
|
62
|
+
key: string;
|
|
63
|
+
variations: LaunchdarklyVariation[];
|
|
64
|
+
version: number;
|
|
65
|
+
environments: Record<string, LaunchdarklyFlagEnv>;
|
|
66
|
+
}
|
|
67
|
+
/** One project: its environments (and which need approvals) and its flags. */
|
|
68
|
+
export interface LaunchdarklyProject {
|
|
69
|
+
environments: string[];
|
|
70
|
+
approvals: string[];
|
|
71
|
+
flags: Record<string, LaunchdarklyFlag>;
|
|
72
|
+
}
|
|
73
|
+
/** The simulated LaunchDarkly's state: projects by key. */
|
|
74
|
+
export interface LaunchdarklyState {
|
|
75
|
+
projects: Record<string, LaunchdarklyProject>;
|
|
76
|
+
}
|
|
77
|
+
/** What a scenario or test seeds the simulated LaunchDarkly with (`seed: { launchdarkly: … }`). */
|
|
78
|
+
export interface LaunchdarklySeed {
|
|
79
|
+
projects: Record<string, {
|
|
80
|
+
environments?: string[];
|
|
81
|
+
/** Environments whose flag changes need an approval request (LD6). */
|
|
82
|
+
approvals?: string[];
|
|
83
|
+
flags?: Record<string, {
|
|
84
|
+
variations?: Array<{
|
|
85
|
+
value: unknown;
|
|
86
|
+
name?: string;
|
|
87
|
+
}>;
|
|
88
|
+
/** Environment key → targets that exist already (made "by a human", so unmanaged). */
|
|
89
|
+
targets?: Record<string, Array<{
|
|
90
|
+
contextKind?: string;
|
|
91
|
+
key: string;
|
|
92
|
+
variation: number;
|
|
93
|
+
}>>;
|
|
94
|
+
}>;
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
97
|
+
/** The simulated LaunchDarkly API, registered in `PROVIDERS` (packages/sim/src/state.ts) and served under `/launchdarkly`. */
|
|
98
|
+
export declare const launchdarklySim: ProviderSim<LaunchdarklyState, LaunchdarklySeed>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulated LaunchDarkly: feature flags per project, with each environment's individual targets (a context key of a
|
|
3
|
+
* context kind served one variation).
|
|
4
|
+
*
|
|
5
|
+
* Assumptions about the real API that this fake encodes, each marked with how far it is checked. "Verified" means
|
|
6
|
+
* against LaunchDarkly's published REST API reference (fetched 2026-10-11): the API overview
|
|
7
|
+
* https://launchdarkly.com/docs/api, "Get feature flag" https://launchdarkly.com/docs/api/feature-flags/get-feature-flag
|
|
8
|
+
* and "Update feature flag" https://launchdarkly.com/docs/api/feature-flags/patch-feature-flag; see
|
|
9
|
+
* docs/api-verification.md:
|
|
10
|
+
* LD1. The access token is the whole `Authorization` value, with no `Bearer` prefix (verified: "The value of the
|
|
11
|
+
* `Authorization` header must be your access token"). The base URL is https://app.launchdarkly.com/api/v2
|
|
12
|
+
* (verified from the OpenAPI document's location; federal and EU instances use other hosts). The sim
|
|
13
|
+
* refuses a `Bearer` header with 401.
|
|
14
|
+
* LD2. `GET /flags/{projectKey}/{featureFlagKey}?env=<key>` answers the flag with `variations[]` (`_id`, `value`,
|
|
15
|
+
* optional `name`) and `environments.<key>` restricted to that environment (verified). An unknown project or
|
|
16
|
+
* flag is 404 (verified: "Invalid resource identifier"). An environment the project lacks is absent from
|
|
17
|
+
* `environments` (unverified: whether LaunchDarkly answers 404 or an empty map instead; the adapter
|
|
18
|
+
* reports either as not found).
|
|
19
|
+
* LD3. An environment's individual targets are `targets[]` (context kind `user`) and `contextTargets[]` (every
|
|
20
|
+
* other kind), items `{ contextKind, values, variation }` where `variation` is an index into `variations`
|
|
21
|
+
* (verified). `contextTargets` also lists, for each variation with user targets, a placeholder
|
|
22
|
+
* `{ contextKind: "user", values: [] }` (unverified, observed behaviour reported by SDK users; the adapter
|
|
23
|
+
* reads both arrays and ignores empty entries, so it does not depend on it).
|
|
24
|
+
* LD4. `PATCH /flags/{projectKey}/{featureFlagKey}` with `Content-Type: application/json;
|
|
25
|
+
* domain-model=launchdarkly.semanticpatch` and `{ environmentKey, instructions, comment? }` applies the
|
|
26
|
+
* instructions; without that media type the body is read as a JSON patch and a semantic patch is 400
|
|
27
|
+
* (verified). It answers 200 with the whole flag (verified). Instructions are all or nothing: one invalid
|
|
28
|
+
* instruction and nothing changes (verified).
|
|
29
|
+
* LD5. `addTargets { contextKind, values, variationId }` (`contextKind` defaults to `user`) is refused (400)
|
|
30
|
+
* when it would make a context key targeted by two variations (verified that it is an error; the status
|
|
31
|
+
* is unverified). Adding a key already targeted by the same variation changes nothing and succeeds
|
|
32
|
+
* (unverified: the docs say only that a semantic patch updates the flag "only where needed"; pinned by the
|
|
33
|
+
* contract suite). `removeTargets` with the same fields does nothing for a key not targeted (verified).
|
|
34
|
+
* LD6. An environment that requires approvals answers 405 to a flag change (verified); a concurrent change
|
|
35
|
+
* answers 409, to be retried (verified). The error body is `{ code, message }` (verified).
|
|
36
|
+
* LD7. The PATCH takes effect at once: the next GET shows it (unverified; pinned by the contract suite).
|
|
37
|
+
*/
|
|
38
|
+
import { Reply, route, router } from "../provider.js";
|
|
39
|
+
/** The media type of a semantic patch (LD4). */
|
|
40
|
+
export const LAUNCHDARKLY_SEMANTIC_PATCH = "application/json; domain-model=launchdarkly.semanticpatch";
|
|
41
|
+
const BOOLEAN = [
|
|
42
|
+
{ value: true, name: "on" },
|
|
43
|
+
{ value: false, name: "off" },
|
|
44
|
+
];
|
|
45
|
+
/** The simulated LaunchDarkly API, registered in `PROVIDERS` (packages/sim/src/state.ts) and served under `/launchdarkly`. */
|
|
46
|
+
export const launchdarklySim = {
|
|
47
|
+
env: { token: "LAUNCHDARKLY_ACCESS_TOKEN", url: "LAUNCHDARKLY_API_URL", testToken: "api-sim-launchdarkly" },
|
|
48
|
+
// LD1: the token alone in Authorization; a `Bearer` header is refused, so an adapter that sends one fails its tests.
|
|
49
|
+
authorized: (headers) => /^\S+$/.test(headers.authorization ?? "") && !/^Bearer$/i.test(headers.authorization ?? ""),
|
|
50
|
+
defaultSeed: { projects: { demo: { environments: ["preview", "production"], flags: { "new-checkout": {} } } } },
|
|
51
|
+
reset(core, seed) {
|
|
52
|
+
const projects = {};
|
|
53
|
+
for (const [key, p] of Object.entries(seed?.projects ?? {})) {
|
|
54
|
+
const environments = p.environments ?? ["preview", "production"];
|
|
55
|
+
const flags = {};
|
|
56
|
+
for (const [flagKey, f] of Object.entries(p.flags ?? {}))
|
|
57
|
+
flags[flagKey] = flag(core, flagKey, environments, f);
|
|
58
|
+
projects[key] = { environments, approvals: p.approvals ?? [], flags };
|
|
59
|
+
}
|
|
60
|
+
return { projects };
|
|
61
|
+
},
|
|
62
|
+
/**
|
|
63
|
+
* `target.<flag>/<environment>/<contextKind>/<key>` (or `launchdarkly:<project>.target.…`): "delete", or a
|
|
64
|
+
* variation name or index to move the target to — what a human in the LaunchDarkly console would do.
|
|
65
|
+
*/
|
|
66
|
+
drift(_core, state, { key, rest, value, only }) {
|
|
67
|
+
if (!rest.startsWith("target."))
|
|
68
|
+
return false;
|
|
69
|
+
const [flagKey, envKey, kind, ...target] = rest.slice("target.".length).split("/");
|
|
70
|
+
const contextKey = target.join("/");
|
|
71
|
+
let hit = false;
|
|
72
|
+
for (const [id, p] of Object.entries(state.projects)) {
|
|
73
|
+
if (only !== undefined && id !== only)
|
|
74
|
+
continue;
|
|
75
|
+
const f = p.flags[flagKey ?? ""];
|
|
76
|
+
const env = f?.environments[envKey ?? ""];
|
|
77
|
+
const t = env?.targets.find((x) => x.contextKind === kind && x.key === contextKey);
|
|
78
|
+
if (!f || !env || !t)
|
|
79
|
+
continue;
|
|
80
|
+
hit = true;
|
|
81
|
+
if (value === "delete")
|
|
82
|
+
env.targets = env.targets.filter((x) => x !== t);
|
|
83
|
+
else
|
|
84
|
+
t.variation = variationIndex(f, value, key);
|
|
85
|
+
env.version++;
|
|
86
|
+
}
|
|
87
|
+
if (!hit)
|
|
88
|
+
throw new Error(`drift ${key}: no such target`);
|
|
89
|
+
return true;
|
|
90
|
+
},
|
|
91
|
+
routes(core, state, req) {
|
|
92
|
+
return routes(core, state, req);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
function variationIndex(f, value, key) {
|
|
96
|
+
const byName = f.variations.findIndex((v) => v.name === value);
|
|
97
|
+
if (byName >= 0)
|
|
98
|
+
return byName;
|
|
99
|
+
if (/^\d+$/.test(value) && Number(value) < f.variations.length)
|
|
100
|
+
return Number(value);
|
|
101
|
+
throw new Error(`drift ${key}: flag ${f.key} has no variation ${value}`);
|
|
102
|
+
}
|
|
103
|
+
/** The API, one route per method and path, under `/api/v2` (part of the base URL). */
|
|
104
|
+
const routes = router([
|
|
105
|
+
route("GET", "/flags/:project/:flag", ({ state, params, url }) => {
|
|
106
|
+
const f = state.projects[params.project]?.flags[params.flag];
|
|
107
|
+
if (!f)
|
|
108
|
+
return ldError(404, "not_found", "Unknown resource");
|
|
109
|
+
return new Reply(200, publicFlag(f, url.searchParams.get("env")));
|
|
110
|
+
}),
|
|
111
|
+
route("PATCH", "/flags/:project/:flag", ({ state, params, body, headers }) => {
|
|
112
|
+
if (!(headers["content-type"] ?? "").replace(/\s+/g, "").includes("domain-model=launchdarkly.semanticpatch")) {
|
|
113
|
+
return ldError(400, "invalid_request", "Invalid JSON patch: expected an array of operations");
|
|
114
|
+
}
|
|
115
|
+
const p = state.projects[params.project];
|
|
116
|
+
const f = p?.flags[params.flag];
|
|
117
|
+
if (!p || !f)
|
|
118
|
+
return ldError(404, "not_found", "Unknown resource");
|
|
119
|
+
const b = (body ?? {});
|
|
120
|
+
if (typeof b.environmentKey !== "string" || !f.environments[b.environmentKey])
|
|
121
|
+
return ldError(400, "invalid_request", "environmentKey is required and must name an environment");
|
|
122
|
+
if (!Array.isArray(b.instructions) || b.instructions.length === 0)
|
|
123
|
+
return ldError(400, "invalid_request", "instructions are required");
|
|
124
|
+
if (p.approvals.includes(b.environmentKey))
|
|
125
|
+
return ldError(405, "method_not_allowed", "This environment requires approval for changes to flags");
|
|
126
|
+
const env = f.environments[b.environmentKey];
|
|
127
|
+
// All or nothing (LD4): work on a copy, keep it only if every instruction was valid.
|
|
128
|
+
const next = env.targets.map((t) => ({ ...t }));
|
|
129
|
+
for (const ins of b.instructions) {
|
|
130
|
+
const problem = applyInstruction(f, next, ins);
|
|
131
|
+
if (problem)
|
|
132
|
+
return ldError(400, "invalid_request", problem);
|
|
133
|
+
}
|
|
134
|
+
if (JSON.stringify(next) !== JSON.stringify(env.targets)) {
|
|
135
|
+
env.targets = next;
|
|
136
|
+
env.version++;
|
|
137
|
+
f.version++;
|
|
138
|
+
}
|
|
139
|
+
return new Reply(200, publicFlag(f, null));
|
|
140
|
+
}),
|
|
141
|
+
], () => ldError(404, "not_found", "Unknown resource"));
|
|
142
|
+
/** One `addTargets` / `removeTargets` instruction against `targets`; a message when it is invalid. */
|
|
143
|
+
function applyInstruction(f, targets, ins) {
|
|
144
|
+
const i = parseInstruction(f, ins);
|
|
145
|
+
if (typeof i === "string")
|
|
146
|
+
return i;
|
|
147
|
+
const { contextKind, variation } = i;
|
|
148
|
+
for (const key of i.values) {
|
|
149
|
+
const at = targets.findIndex((t) => t.contextKind === contextKind && t.key === key);
|
|
150
|
+
if (i.kind === "removeTargets") {
|
|
151
|
+
if (at >= 0 && targets[at].variation === variation)
|
|
152
|
+
targets.splice(at, 1);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (at >= 0 && targets[at].variation !== variation)
|
|
156
|
+
return `${contextKind} ${key} is already targeted by another variation`;
|
|
157
|
+
if (at < 0)
|
|
158
|
+
targets.push({ contextKind, key, variation, createdBy: "api" });
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
/** One instruction, validated; a message when it is not one the sim implements or is malformed. */
|
|
163
|
+
function parseInstruction(f, ins) {
|
|
164
|
+
const i = (ins ?? {});
|
|
165
|
+
if (i.kind !== "addTargets" && i.kind !== "removeTargets")
|
|
166
|
+
return `unsupported instruction kind ${String(i.kind)}`;
|
|
167
|
+
const variation = f.variations.findIndex((v) => v._id === i.variationId);
|
|
168
|
+
if (variation < 0)
|
|
169
|
+
return `unknown variationId ${String(i.variationId)}`;
|
|
170
|
+
if (!Array.isArray(i.values) || i.values.some((v) => typeof v !== "string"))
|
|
171
|
+
return "values must be a list of context keys";
|
|
172
|
+
const contextKind = typeof i.contextKind === "string" && i.contextKind !== "" ? i.contextKind : "user";
|
|
173
|
+
return { kind: i.kind, contextKind, values: i.values, variation };
|
|
174
|
+
}
|
|
175
|
+
/** The flag representation (LD2, LD3), restricted to one environment when `env` names one. */
|
|
176
|
+
function publicFlag(f, env) {
|
|
177
|
+
const environments = {};
|
|
178
|
+
for (const [key, e] of Object.entries(f.environments)) {
|
|
179
|
+
if (env !== null && key !== env)
|
|
180
|
+
continue;
|
|
181
|
+
const user = groups(e.targets.filter((t) => t.contextKind === "user"));
|
|
182
|
+
const other = groups(e.targets.filter((t) => t.contextKind !== "user"));
|
|
183
|
+
const placeholders = user.map((g) => ({ contextKind: "user", values: [], variation: g.variation }));
|
|
184
|
+
environments[key] = { on: e.on, version: e.version, _environmentName: key, targets: user, contextTargets: [...other, ...placeholders] };
|
|
185
|
+
}
|
|
186
|
+
return { key: f.key, _version: f.version, variations: f.variations, environments };
|
|
187
|
+
}
|
|
188
|
+
/** Targets grouped the way LaunchDarkly lists them: one entry per (kind, variation). */
|
|
189
|
+
function groups(targets) {
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const t of targets) {
|
|
192
|
+
const g = out.find((x) => x.contextKind === t.contextKind && x.variation === t.variation);
|
|
193
|
+
if (g)
|
|
194
|
+
g.values.push(t.key);
|
|
195
|
+
else
|
|
196
|
+
out.push({ contextKind: t.contextKind, values: [t.key], variation: t.variation });
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
function flag(core, key, environments, seed) {
|
|
201
|
+
const variations = (seed.variations ?? BOOLEAN).map((v) => ({ _id: core.nextId("var_"), value: v.value, ...(v.name !== undefined ? { name: v.name } : {}) }));
|
|
202
|
+
const envs = {};
|
|
203
|
+
for (const e of environments) {
|
|
204
|
+
const targets = (seed.targets?.[e] ?? []).map((t) => ({ contextKind: t.contextKind ?? "user", key: t.key, variation: t.variation, createdBy: "sim" }));
|
|
205
|
+
envs[e] = { on: true, version: 1, targets };
|
|
206
|
+
}
|
|
207
|
+
return { key, variations, version: 1, environments: envs };
|
|
208
|
+
}
|
|
209
|
+
/** LaunchDarkly's error body (LD6). */
|
|
210
|
+
function ldError(status, code, message) {
|
|
211
|
+
return new Reply(status, { code, message });
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=launchdarkly.js.map
|