@stardeck-customer-apps/testing 0.1.1 → 0.3.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/SKILL.md +7 -0
- package/dist/index.d.mts +91 -1
- package/dist/index.d.ts +91 -1
- package/dist/index.js +207 -12
- package/dist/index.mjs +209 -12
- package/dist/next/headers-shim.js +2 -0
- package/dist/next/headers-shim.mjs +2 -0
- package/dist/setup.js +149 -9
- package/dist/setup.mjs +149 -9
- package/package.json +8 -2
package/SKILL.md
CHANGED
|
@@ -85,6 +85,9 @@ describeWorkflow("checkout", () => {
|
|
|
85
85
|
auth flows (`__stardeck:sess`).
|
|
86
86
|
- `app.inbox` — emails sent through the email-sdk: `.latest(addr?)`,
|
|
87
87
|
`.to(addr)`, `.all()`, `.count`, `.clear()`.
|
|
88
|
+
- `app.identities` — the platform-identity directory created through
|
|
89
|
+
integrations-sdk `client.identities`: `.get(id)`, `.links(id)`, `.all()`,
|
|
90
|
+
`.count`, `.clear()`.
|
|
88
91
|
- `app.query(sql, params?)` / `app.db` — direct database access for
|
|
89
92
|
assertions and seeding.
|
|
90
93
|
- `callRoute(handler, opts)` — invoke an App Router route handler with a real
|
|
@@ -104,6 +107,10 @@ describeWorkflow("checkout", () => {
|
|
|
104
107
|
- `getSession()` / `requireAuth()` from project-auth — resolves the
|
|
105
108
|
`asUser(...)` user (header fast path) or issued session cookies.
|
|
106
109
|
- `EmailClient.send()` — captured in `app.inbox`, never delivered.
|
|
110
|
+
- `client.identities` from integrations-sdk (create/get/update/list accounts &
|
|
111
|
+
persons, attach channel links) — served offline by the simulated directory;
|
|
112
|
+
`update` replaces the `profile` object (not a merge), like the control plane.
|
|
113
|
+
Inspect via `app.identities`. Merge/archive are dashboard-only — not simulated.
|
|
107
114
|
- `next/headers` (`headers()`/`cookies()`) inside handlers under `callRoute`.
|
|
108
115
|
|
|
109
116
|
## Rules
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PGlite } from '@electric-sql/pglite';
|
|
2
|
+
import { ModuleSchemaOp, ModuleDataPort, IdentityClient } from '@stardeck-customer-apps/core';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The user shape returned by project-auth's `getSession()`. Mirrors the
|
|
@@ -43,6 +44,47 @@ interface TestInbox {
|
|
|
43
44
|
clear(): void;
|
|
44
45
|
get count(): number;
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* A platform identity captured by the simulated directory. Mirrors the `Identity`
|
|
49
|
+
* shape returned by @stardeck-customer-apps/integrations-sdk's `client.identities`
|
|
50
|
+
* — keep in sync.
|
|
51
|
+
*/
|
|
52
|
+
interface CapturedIdentity {
|
|
53
|
+
id: string;
|
|
54
|
+
type: "person" | "account";
|
|
55
|
+
parentId: string | null;
|
|
56
|
+
displayName: string | null;
|
|
57
|
+
profile: Record<string, unknown>;
|
|
58
|
+
status: "active" | "merged" | "archived";
|
|
59
|
+
mergedIntoId: string | null;
|
|
60
|
+
externalRef: string | null;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
updatedAt: string;
|
|
63
|
+
}
|
|
64
|
+
/** A channel identifier / login attached to a person identity. */
|
|
65
|
+
interface CapturedIdentityLink {
|
|
66
|
+
id: string;
|
|
67
|
+
identityId: string;
|
|
68
|
+
kind: string;
|
|
69
|
+
externalId: string;
|
|
70
|
+
verified: boolean;
|
|
71
|
+
createdAt: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Inspect the simulated platform-identity directory — the in-memory stand-in for
|
|
75
|
+
* the control plane that `client.identities` talks to in tests. Identities and
|
|
76
|
+
* links created by app code under test land here; assert on them like `inbox`.
|
|
77
|
+
*/
|
|
78
|
+
interface TestDirectory {
|
|
79
|
+
/** All identities, oldest first. */
|
|
80
|
+
all(): CapturedIdentity[];
|
|
81
|
+
/** A single identity by id, or undefined. */
|
|
82
|
+
get(id: string): CapturedIdentity | undefined;
|
|
83
|
+
/** Channel/login links attached to an identity. */
|
|
84
|
+
links(identityId: string): CapturedIdentityLink[];
|
|
85
|
+
clear(): void;
|
|
86
|
+
get count(): number;
|
|
87
|
+
}
|
|
46
88
|
interface TestAppOptions {
|
|
47
89
|
/**
|
|
48
90
|
* Path to the schema.sql snapshot generated by
|
|
@@ -70,6 +112,8 @@ interface TestApp {
|
|
|
70
112
|
db: PGlite;
|
|
71
113
|
/** Captured outbound email. */
|
|
72
114
|
inbox: TestInbox;
|
|
115
|
+
/** Inspect the simulated platform-identity directory (`client.identities`). */
|
|
116
|
+
identities: TestDirectory;
|
|
73
117
|
/** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
|
|
74
118
|
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
75
119
|
/**
|
|
@@ -97,6 +141,52 @@ interface TestApp {
|
|
|
97
141
|
*/
|
|
98
142
|
declare function createTestApp(options?: TestAppOptions): Promise<TestApp>;
|
|
99
143
|
|
|
144
|
+
/** The minimal slice of a module definition the harness needs to stand it up. */
|
|
145
|
+
interface MountableModule {
|
|
146
|
+
schema: ModuleSchemaOp[];
|
|
147
|
+
}
|
|
148
|
+
interface ModuleSeedDeps {
|
|
149
|
+
/** Raw-SQL data port over the harness PGlite — what the module engine reads/writes through. */
|
|
150
|
+
data: ModuleDataPort;
|
|
151
|
+
/** Identity client backed by the simulated platform identity directory. */
|
|
152
|
+
identities: IdentityClient;
|
|
153
|
+
}
|
|
154
|
+
interface CreateModuleAppOptions {
|
|
155
|
+
/** Modules whose install schema is rendered to DDL and applied to the test DB. */
|
|
156
|
+
modules: MountableModule[];
|
|
157
|
+
/**
|
|
158
|
+
* Seed run once after the schema is applied, and again on every `reset()`.
|
|
159
|
+
* Receives the same data port + identity client the module engine uses, so a
|
|
160
|
+
* module's own `seed*` function drops straight in.
|
|
161
|
+
*/
|
|
162
|
+
seed?: (deps: ModuleSeedDeps) => Promise<void>;
|
|
163
|
+
/** Allow real outbound network (default false). */
|
|
164
|
+
allowNetwork?: boolean;
|
|
165
|
+
}
|
|
166
|
+
interface ModuleApp extends ModuleSeedDeps {
|
|
167
|
+
/** The underlying harness app (db, identities inspector, callRoute, asUser, …). */
|
|
168
|
+
app: TestApp;
|
|
169
|
+
/** Reset to a clean slate: drop + re-apply the module schema, clear the sim, re-run the seed. */
|
|
170
|
+
reset(): Promise<void>;
|
|
171
|
+
/** Tear down (restore global fetch, close the db). */
|
|
172
|
+
close(): Promise<void>;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Stand a capability module up on the in-process platform simulator — the **L2**
|
|
176
|
+
* harness. It renders the module's install schema to real DDL (the SAME renderer
|
|
177
|
+
* the control plane uses, drift-tested in apps/web), applies it to PGlite, wires a
|
|
178
|
+
* raw-SQL data port over it, and a real `integrations-sdk` identity client talking
|
|
179
|
+
* to the simulated identity directory (so scoping + governance behave as in prod).
|
|
180
|
+
*
|
|
181
|
+
* That's a near-end-to-end test of a module's runtime — schema + data port +
|
|
182
|
+
* identity integration + engine — minus the HTTP/RSC shell (those belong to a
|
|
183
|
+
* real-deploy smoke). The same seams back the local dev playground.
|
|
184
|
+
*
|
|
185
|
+
* Adding a module costs one co-located test file calling this with its own
|
|
186
|
+
* definition + seed; there is no per-module harness to maintain.
|
|
187
|
+
*/
|
|
188
|
+
declare function createModuleApp(options: CreateModuleAppOptions): Promise<ModuleApp>;
|
|
189
|
+
|
|
100
190
|
type RouteHandler<Req extends Request, P> = (request: Req, context: {
|
|
101
191
|
params: Promise<P>;
|
|
102
192
|
}) => Promise<Response> | Response;
|
|
@@ -140,4 +230,4 @@ declare const TEST_ENV_DEFAULTS: {
|
|
|
140
230
|
/** Default location of the DDL snapshot written by `generate-types`. */
|
|
141
231
|
declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
142
232
|
|
|
143
|
-
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
|
|
233
|
+
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CreateModuleAppOptions, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type ModuleApp, type ModuleSeedDeps, type MountableModule, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createModuleApp, createTestApp, describeWorkflow, parseWorkflowName };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PGlite } from '@electric-sql/pglite';
|
|
2
|
+
import { ModuleSchemaOp, ModuleDataPort, IdentityClient } from '@stardeck-customer-apps/core';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The user shape returned by project-auth's `getSession()`. Mirrors the
|
|
@@ -43,6 +44,47 @@ interface TestInbox {
|
|
|
43
44
|
clear(): void;
|
|
44
45
|
get count(): number;
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* A platform identity captured by the simulated directory. Mirrors the `Identity`
|
|
49
|
+
* shape returned by @stardeck-customer-apps/integrations-sdk's `client.identities`
|
|
50
|
+
* — keep in sync.
|
|
51
|
+
*/
|
|
52
|
+
interface CapturedIdentity {
|
|
53
|
+
id: string;
|
|
54
|
+
type: "person" | "account";
|
|
55
|
+
parentId: string | null;
|
|
56
|
+
displayName: string | null;
|
|
57
|
+
profile: Record<string, unknown>;
|
|
58
|
+
status: "active" | "merged" | "archived";
|
|
59
|
+
mergedIntoId: string | null;
|
|
60
|
+
externalRef: string | null;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
updatedAt: string;
|
|
63
|
+
}
|
|
64
|
+
/** A channel identifier / login attached to a person identity. */
|
|
65
|
+
interface CapturedIdentityLink {
|
|
66
|
+
id: string;
|
|
67
|
+
identityId: string;
|
|
68
|
+
kind: string;
|
|
69
|
+
externalId: string;
|
|
70
|
+
verified: boolean;
|
|
71
|
+
createdAt: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Inspect the simulated platform-identity directory — the in-memory stand-in for
|
|
75
|
+
* the control plane that `client.identities` talks to in tests. Identities and
|
|
76
|
+
* links created by app code under test land here; assert on them like `inbox`.
|
|
77
|
+
*/
|
|
78
|
+
interface TestDirectory {
|
|
79
|
+
/** All identities, oldest first. */
|
|
80
|
+
all(): CapturedIdentity[];
|
|
81
|
+
/** A single identity by id, or undefined. */
|
|
82
|
+
get(id: string): CapturedIdentity | undefined;
|
|
83
|
+
/** Channel/login links attached to an identity. */
|
|
84
|
+
links(identityId: string): CapturedIdentityLink[];
|
|
85
|
+
clear(): void;
|
|
86
|
+
get count(): number;
|
|
87
|
+
}
|
|
46
88
|
interface TestAppOptions {
|
|
47
89
|
/**
|
|
48
90
|
* Path to the schema.sql snapshot generated by
|
|
@@ -70,6 +112,8 @@ interface TestApp {
|
|
|
70
112
|
db: PGlite;
|
|
71
113
|
/** Captured outbound email. */
|
|
72
114
|
inbox: TestInbox;
|
|
115
|
+
/** Inspect the simulated platform-identity directory (`client.identities`). */
|
|
116
|
+
identities: TestDirectory;
|
|
73
117
|
/** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
|
|
74
118
|
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
75
119
|
/**
|
|
@@ -97,6 +141,52 @@ interface TestApp {
|
|
|
97
141
|
*/
|
|
98
142
|
declare function createTestApp(options?: TestAppOptions): Promise<TestApp>;
|
|
99
143
|
|
|
144
|
+
/** The minimal slice of a module definition the harness needs to stand it up. */
|
|
145
|
+
interface MountableModule {
|
|
146
|
+
schema: ModuleSchemaOp[];
|
|
147
|
+
}
|
|
148
|
+
interface ModuleSeedDeps {
|
|
149
|
+
/** Raw-SQL data port over the harness PGlite — what the module engine reads/writes through. */
|
|
150
|
+
data: ModuleDataPort;
|
|
151
|
+
/** Identity client backed by the simulated platform identity directory. */
|
|
152
|
+
identities: IdentityClient;
|
|
153
|
+
}
|
|
154
|
+
interface CreateModuleAppOptions {
|
|
155
|
+
/** Modules whose install schema is rendered to DDL and applied to the test DB. */
|
|
156
|
+
modules: MountableModule[];
|
|
157
|
+
/**
|
|
158
|
+
* Seed run once after the schema is applied, and again on every `reset()`.
|
|
159
|
+
* Receives the same data port + identity client the module engine uses, so a
|
|
160
|
+
* module's own `seed*` function drops straight in.
|
|
161
|
+
*/
|
|
162
|
+
seed?: (deps: ModuleSeedDeps) => Promise<void>;
|
|
163
|
+
/** Allow real outbound network (default false). */
|
|
164
|
+
allowNetwork?: boolean;
|
|
165
|
+
}
|
|
166
|
+
interface ModuleApp extends ModuleSeedDeps {
|
|
167
|
+
/** The underlying harness app (db, identities inspector, callRoute, asUser, …). */
|
|
168
|
+
app: TestApp;
|
|
169
|
+
/** Reset to a clean slate: drop + re-apply the module schema, clear the sim, re-run the seed. */
|
|
170
|
+
reset(): Promise<void>;
|
|
171
|
+
/** Tear down (restore global fetch, close the db). */
|
|
172
|
+
close(): Promise<void>;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Stand a capability module up on the in-process platform simulator — the **L2**
|
|
176
|
+
* harness. It renders the module's install schema to real DDL (the SAME renderer
|
|
177
|
+
* the control plane uses, drift-tested in apps/web), applies it to PGlite, wires a
|
|
178
|
+
* raw-SQL data port over it, and a real `integrations-sdk` identity client talking
|
|
179
|
+
* to the simulated identity directory (so scoping + governance behave as in prod).
|
|
180
|
+
*
|
|
181
|
+
* That's a near-end-to-end test of a module's runtime — schema + data port +
|
|
182
|
+
* identity integration + engine — minus the HTTP/RSC shell (those belong to a
|
|
183
|
+
* real-deploy smoke). The same seams back the local dev playground.
|
|
184
|
+
*
|
|
185
|
+
* Adding a module costs one co-located test file calling this with its own
|
|
186
|
+
* definition + seed; there is no per-module harness to maintain.
|
|
187
|
+
*/
|
|
188
|
+
declare function createModuleApp(options: CreateModuleAppOptions): Promise<ModuleApp>;
|
|
189
|
+
|
|
100
190
|
type RouteHandler<Req extends Request, P> = (request: Req, context: {
|
|
101
191
|
params: Promise<P>;
|
|
102
192
|
}) => Promise<Response> | Response;
|
|
@@ -140,4 +230,4 @@ declare const TEST_ENV_DEFAULTS: {
|
|
|
140
230
|
/** Default location of the DDL snapshot written by `generate-types`. */
|
|
141
231
|
declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
142
232
|
|
|
143
|
-
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
|
|
233
|
+
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CreateModuleAppOptions, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type ModuleApp, type ModuleSeedDeps, type MountableModule, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createModuleApp, createTestApp, describeWorkflow, parseWorkflowName };
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ __export(index_exports, {
|
|
|
36
36
|
TEST_ENV_DEFAULTS: () => TEST_ENV_DEFAULTS,
|
|
37
37
|
WORKFLOW_NAME_PREFIX: () => WORKFLOW_NAME_PREFIX,
|
|
38
38
|
callRoute: () => callRoute,
|
|
39
|
+
createModuleApp: () => createModuleApp,
|
|
39
40
|
createTestApp: () => createTestApp,
|
|
40
41
|
describeWorkflow: () => describeWorkflow,
|
|
41
42
|
parseWorkflowName: () => parseWorkflowName
|
|
@@ -43,7 +44,7 @@ __export(index_exports, {
|
|
|
43
44
|
module.exports = __toCommonJS(index_exports);
|
|
44
45
|
|
|
45
46
|
// src/test-app.ts
|
|
46
|
-
var
|
|
47
|
+
var import_node_crypto4 = __toESM(require("crypto"));
|
|
47
48
|
var import_node_fs = require("fs");
|
|
48
49
|
var import_node_path = require("path");
|
|
49
50
|
|
|
@@ -96,6 +97,8 @@ var state = globalSingleton("state", () => ({
|
|
|
96
97
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
97
98
|
emails: [],
|
|
98
99
|
emailCounter: 0,
|
|
100
|
+
identities: /* @__PURE__ */ new Map(),
|
|
101
|
+
identityLinks: [],
|
|
99
102
|
allowNetwork: false
|
|
100
103
|
}));
|
|
101
104
|
function requireDb() {
|
|
@@ -155,8 +158,8 @@ function verifyDeploymentAuthHeader(secret, header) {
|
|
|
155
158
|
return null;
|
|
156
159
|
}
|
|
157
160
|
if (payload.type !== "deployment-request") return null;
|
|
158
|
-
const
|
|
159
|
-
if (Math.abs(
|
|
161
|
+
const now2 = Math.floor(Date.now() / 1e3);
|
|
162
|
+
if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
|
|
160
163
|
return payload;
|
|
161
164
|
}
|
|
162
165
|
|
|
@@ -460,6 +463,7 @@ var FIELD_TYPE_TO_PG = {
|
|
|
460
463
|
currency: "numeric",
|
|
461
464
|
rating: "integer",
|
|
462
465
|
relation: "uuid",
|
|
466
|
+
identity: "uuid",
|
|
463
467
|
file_ref: "jsonb",
|
|
464
468
|
file_refs: "jsonb",
|
|
465
469
|
json: "jsonb"
|
|
@@ -637,6 +641,153 @@ function createInbox() {
|
|
|
637
641
|
};
|
|
638
642
|
}
|
|
639
643
|
|
|
644
|
+
// src/simulator/identities.ts
|
|
645
|
+
var import_node_crypto3 = __toESM(require("crypto"));
|
|
646
|
+
var LINK_KINDS = /* @__PURE__ */ new Set([
|
|
647
|
+
"line",
|
|
648
|
+
"facebook",
|
|
649
|
+
"instagram",
|
|
650
|
+
"email",
|
|
651
|
+
"phone",
|
|
652
|
+
"project_auth_user",
|
|
653
|
+
"dashboard_user"
|
|
654
|
+
]);
|
|
655
|
+
function now() {
|
|
656
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
657
|
+
}
|
|
658
|
+
function linksFor(identityId) {
|
|
659
|
+
return state.identityLinks.filter((l) => l.identityId === identityId);
|
|
660
|
+
}
|
|
661
|
+
async function readBody(request) {
|
|
662
|
+
try {
|
|
663
|
+
return await request.json();
|
|
664
|
+
} catch {
|
|
665
|
+
return {};
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
function handleList(request) {
|
|
669
|
+
const typeParam = new URL(request.url).searchParams.get("type");
|
|
670
|
+
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
671
|
+
const identities = [...state.identities.values()];
|
|
672
|
+
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
673
|
+
}
|
|
674
|
+
async function handleCreate(request) {
|
|
675
|
+
const body = await readBody(request);
|
|
676
|
+
const type = body.type;
|
|
677
|
+
if (type !== "person" && type !== "account") {
|
|
678
|
+
return failure("type must be 'person' or 'account'");
|
|
679
|
+
}
|
|
680
|
+
let parentId = null;
|
|
681
|
+
if (type === "person" && body.parentId) {
|
|
682
|
+
const parent = state.identities.get(body.parentId);
|
|
683
|
+
if (!parent) return failure("parent identity not found");
|
|
684
|
+
if (parent.type !== "account") {
|
|
685
|
+
return failure("a person's parent must be an account");
|
|
686
|
+
}
|
|
687
|
+
parentId = parent.id;
|
|
688
|
+
}
|
|
689
|
+
const identity = {
|
|
690
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
691
|
+
type,
|
|
692
|
+
parentId,
|
|
693
|
+
displayName: body.displayName ?? null,
|
|
694
|
+
profile: body.profile ?? {},
|
|
695
|
+
status: "active",
|
|
696
|
+
mergedIntoId: null,
|
|
697
|
+
externalRef: body.externalRef ?? null,
|
|
698
|
+
createdAt: now(),
|
|
699
|
+
updatedAt: now()
|
|
700
|
+
};
|
|
701
|
+
state.identities.set(identity.id, identity);
|
|
702
|
+
return success({ identity });
|
|
703
|
+
}
|
|
704
|
+
function handleGet(identityId) {
|
|
705
|
+
const identity = state.identities.get(identityId);
|
|
706
|
+
if (!identity) return failure("identity not found", 404);
|
|
707
|
+
return success({ identity, links: linksFor(identityId) });
|
|
708
|
+
}
|
|
709
|
+
async function handleUpdate(identityId, request) {
|
|
710
|
+
const identity = state.identities.get(identityId);
|
|
711
|
+
if (!identity) return failure("identity not found", 404);
|
|
712
|
+
const body = await readBody(request);
|
|
713
|
+
if (body.displayName !== void 0) {
|
|
714
|
+
identity.displayName = body.displayName;
|
|
715
|
+
}
|
|
716
|
+
if (body.profile !== void 0) {
|
|
717
|
+
identity.profile = body.profile;
|
|
718
|
+
}
|
|
719
|
+
if (body.externalRef !== void 0) {
|
|
720
|
+
identity.externalRef = body.externalRef;
|
|
721
|
+
}
|
|
722
|
+
identity.updatedAt = now();
|
|
723
|
+
return success({ identity });
|
|
724
|
+
}
|
|
725
|
+
async function handleAttachLink(identityId, request) {
|
|
726
|
+
const identity = state.identities.get(identityId);
|
|
727
|
+
if (!identity) return failure("identity not found", 404);
|
|
728
|
+
if (identity.type !== "person") {
|
|
729
|
+
return failure("links attach only to a person identity");
|
|
730
|
+
}
|
|
731
|
+
if (identity.status !== "active") {
|
|
732
|
+
return failure("links attach only to active persons");
|
|
733
|
+
}
|
|
734
|
+
const body = await readBody(request);
|
|
735
|
+
const kind = body.kind;
|
|
736
|
+
const externalId = body.externalId;
|
|
737
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
738
|
+
return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
739
|
+
}
|
|
740
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
741
|
+
return failure("externalId is required");
|
|
742
|
+
}
|
|
743
|
+
const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
|
|
744
|
+
if (existing) {
|
|
745
|
+
if (existing.identityId === identityId) return success({ link: existing });
|
|
746
|
+
return failure("identifier already linked to another identity", 409);
|
|
747
|
+
}
|
|
748
|
+
const link = {
|
|
749
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
750
|
+
identityId,
|
|
751
|
+
kind,
|
|
752
|
+
externalId,
|
|
753
|
+
verified: body.verified === true,
|
|
754
|
+
createdAt: now()
|
|
755
|
+
};
|
|
756
|
+
state.identityLinks.push(link);
|
|
757
|
+
return success({ link });
|
|
758
|
+
}
|
|
759
|
+
async function handleIdentitiesRequest(request, subPath) {
|
|
760
|
+
const method = request.method;
|
|
761
|
+
if (subPath === "" || subPath === "/") {
|
|
762
|
+
if (method === "GET") return handleList(request);
|
|
763
|
+
if (method === "POST") return handleCreate(request);
|
|
764
|
+
}
|
|
765
|
+
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
766
|
+
if (linksMatch && method === "POST") {
|
|
767
|
+
return handleAttachLink(linksMatch[1], request);
|
|
768
|
+
}
|
|
769
|
+
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
770
|
+
if (singleMatch) {
|
|
771
|
+
if (method === "GET") return handleGet(singleMatch[1]);
|
|
772
|
+
if (method === "PATCH") return handleUpdate(singleMatch[1], request);
|
|
773
|
+
}
|
|
774
|
+
return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
|
|
775
|
+
}
|
|
776
|
+
function createDirectory() {
|
|
777
|
+
return {
|
|
778
|
+
all: () => [...state.identities.values()],
|
|
779
|
+
get: (id) => state.identities.get(id),
|
|
780
|
+
links: (identityId) => linksFor(identityId),
|
|
781
|
+
clear: () => {
|
|
782
|
+
state.identities.clear();
|
|
783
|
+
state.identityLinks = [];
|
|
784
|
+
},
|
|
785
|
+
get count() {
|
|
786
|
+
return state.identities.size;
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
640
791
|
// src/simulator/router.ts
|
|
641
792
|
var fetchHolder = globalSingleton("fetch-holder", () => ({
|
|
642
793
|
originalFetch: null
|
|
@@ -654,7 +805,8 @@ async function handleSimulatedRequest(request, url) {
|
|
|
654
805
|
}
|
|
655
806
|
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
656
807
|
const isEmail = url.pathname === "/api/email/send";
|
|
657
|
-
|
|
808
|
+
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
809
|
+
if (dataStoreMatch || isEmail || identitiesMatch) {
|
|
658
810
|
const authHeader = request.headers.get("X-Stardeck-Auth");
|
|
659
811
|
if (!authHeader) {
|
|
660
812
|
return failure("Missing authentication header", 401);
|
|
@@ -667,28 +819,31 @@ async function handleSimulatedRequest(request, url) {
|
|
|
667
819
|
if (isEmail && request.method === "POST") {
|
|
668
820
|
return handleEmailSend(request);
|
|
669
821
|
}
|
|
822
|
+
if (identitiesMatch) {
|
|
823
|
+
return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
|
|
824
|
+
}
|
|
670
825
|
if (dataStoreMatch) {
|
|
671
826
|
const subPath = dataStoreMatch[1] ?? "";
|
|
672
827
|
const db = requireDb();
|
|
673
|
-
const
|
|
828
|
+
const readBody2 = async () => await request.json();
|
|
674
829
|
if (subPath === "/query" && request.method === "POST") {
|
|
675
|
-
return handleQuery(db, await
|
|
830
|
+
return handleQuery(db, await readBody2());
|
|
676
831
|
}
|
|
677
832
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
678
|
-
return handleMutate(db, await
|
|
833
|
+
return handleMutate(db, await readBody2());
|
|
679
834
|
}
|
|
680
835
|
if (subPath === "/schema" && request.method === "GET") {
|
|
681
836
|
return handleGetSchema(db);
|
|
682
837
|
}
|
|
683
838
|
if (subPath === "/schema/tables" && request.method === "POST") {
|
|
684
|
-
return handleCreateTable(db, await
|
|
839
|
+
return handleCreateTable(db, await readBody2());
|
|
685
840
|
}
|
|
686
841
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
687
|
-
return handleAddColumn(db, await
|
|
842
|
+
return handleAddColumn(db, await readBody2());
|
|
688
843
|
}
|
|
689
844
|
}
|
|
690
845
|
return failure(
|
|
691
|
-
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
|
|
846
|
+
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
|
|
692
847
|
404
|
|
693
848
|
);
|
|
694
849
|
}
|
|
@@ -773,9 +928,11 @@ async function createTestApp(options = {}) {
|
|
|
773
928
|
throw error;
|
|
774
929
|
}
|
|
775
930
|
const inbox = createInbox();
|
|
931
|
+
const directory = createDirectory();
|
|
776
932
|
const app = {
|
|
777
933
|
db,
|
|
778
934
|
inbox,
|
|
935
|
+
identities: directory,
|
|
779
936
|
async query(sql, params = []) {
|
|
780
937
|
const result = await db.query(sql, params);
|
|
781
938
|
return result.rows;
|
|
@@ -791,8 +948,8 @@ async function createTestApp(options = {}) {
|
|
|
791
948
|
issueSession(user) {
|
|
792
949
|
const fullUser = buildUser(user);
|
|
793
950
|
const tokens = {
|
|
794
|
-
accessToken: `test-access-${
|
|
795
|
-
refreshToken: `test-refresh-${
|
|
951
|
+
accessToken: `test-access-${import_node_crypto4.default.randomUUID()}`,
|
|
952
|
+
refreshToken: `test-refresh-${import_node_crypto4.default.randomUUID()}`
|
|
796
953
|
};
|
|
797
954
|
state.sessions.set(tokens.accessToken, fullUser);
|
|
798
955
|
state.refreshSessions.set(tokens.refreshToken, fullUser);
|
|
@@ -806,6 +963,8 @@ async function createTestApp(options = {}) {
|
|
|
806
963
|
state.refreshSessions.clear();
|
|
807
964
|
state.emails = [];
|
|
808
965
|
state.emailCounter = 0;
|
|
966
|
+
state.identities.clear();
|
|
967
|
+
state.identityLinks = [];
|
|
809
968
|
},
|
|
810
969
|
async close() {
|
|
811
970
|
state.db = null;
|
|
@@ -813,6 +972,8 @@ async function createTestApp(options = {}) {
|
|
|
813
972
|
state.sessions.clear();
|
|
814
973
|
state.refreshSessions.clear();
|
|
815
974
|
state.emails = [];
|
|
975
|
+
state.identities.clear();
|
|
976
|
+
state.identityLinks = [];
|
|
816
977
|
uninstallFetchRouter();
|
|
817
978
|
await db.close();
|
|
818
979
|
}
|
|
@@ -820,6 +981,39 @@ async function createTestApp(options = {}) {
|
|
|
820
981
|
return app;
|
|
821
982
|
}
|
|
822
983
|
|
|
984
|
+
// src/module-app.ts
|
|
985
|
+
var import_core = require("@stardeck-customer-apps/core");
|
|
986
|
+
async function createModuleApp(options) {
|
|
987
|
+
const schemaSql = options.modules.map((m) => (0, import_core.renderSchemaOpsToSql)(m.schema)).join("\n\n");
|
|
988
|
+
const app = await createTestApp({ schemaSql, allowNetwork: options.allowNetwork });
|
|
989
|
+
const sql = { query: (text, params) => app.db.query(text, params ?? []) };
|
|
990
|
+
const data = (0, import_core.makeSqlPort)(sql);
|
|
991
|
+
const { createIntegrationsClient } = await import("@stardeck-customer-apps/integrations-sdk");
|
|
992
|
+
const identities = createIntegrationsClient({
|
|
993
|
+
controlPlaneUrl: TEST_ENV_DEFAULTS.CONTROL_PLANE_URL,
|
|
994
|
+
organizationId: TEST_ENV_DEFAULTS.ORGANIZATION_ID,
|
|
995
|
+
projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
|
|
996
|
+
deploymentId: TEST_ENV_DEFAULTS.DEPLOYMENT_ID,
|
|
997
|
+
deploymentSecret: TEST_ENV_DEFAULTS.DEPLOYMENT_SECRET
|
|
998
|
+
}).identities;
|
|
999
|
+
const runSeed = async () => {
|
|
1000
|
+
if (options.seed) await options.seed({ data, identities });
|
|
1001
|
+
};
|
|
1002
|
+
await runSeed();
|
|
1003
|
+
return {
|
|
1004
|
+
app,
|
|
1005
|
+
data,
|
|
1006
|
+
identities,
|
|
1007
|
+
async reset() {
|
|
1008
|
+
await app.reset();
|
|
1009
|
+
await runSeed();
|
|
1010
|
+
},
|
|
1011
|
+
async close() {
|
|
1012
|
+
await app.close();
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
823
1017
|
// src/next/headers-shim.ts
|
|
824
1018
|
var import_node_async_hooks = require("async_hooks");
|
|
825
1019
|
var requestScopeStorage = globalSingleton(
|
|
@@ -917,6 +1111,7 @@ function parseWorkflowName(describeTitle) {
|
|
|
917
1111
|
TEST_ENV_DEFAULTS,
|
|
918
1112
|
WORKFLOW_NAME_PREFIX,
|
|
919
1113
|
callRoute,
|
|
1114
|
+
createModuleApp,
|
|
920
1115
|
createTestApp,
|
|
921
1116
|
describeWorkflow,
|
|
922
1117
|
parseWorkflowName
|