@stardeck-customer-apps/testing 0.1.1 → 0.2.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 +44 -1
- package/dist/index.d.ts +44 -1
- package/dist/index.js +172 -12
- package/dist/index.mjs +172 -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 +2 -1
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
|
@@ -43,6 +43,47 @@ interface TestInbox {
|
|
|
43
43
|
clear(): void;
|
|
44
44
|
get count(): number;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* A platform identity captured by the simulated directory. Mirrors the `Identity`
|
|
48
|
+
* shape returned by @stardeck-customer-apps/integrations-sdk's `client.identities`
|
|
49
|
+
* — keep in sync.
|
|
50
|
+
*/
|
|
51
|
+
interface CapturedIdentity {
|
|
52
|
+
id: string;
|
|
53
|
+
type: "person" | "account";
|
|
54
|
+
parentId: string | null;
|
|
55
|
+
displayName: string | null;
|
|
56
|
+
profile: Record<string, unknown>;
|
|
57
|
+
status: "active" | "merged" | "archived";
|
|
58
|
+
mergedIntoId: string | null;
|
|
59
|
+
externalRef: string | null;
|
|
60
|
+
createdAt: string;
|
|
61
|
+
updatedAt: string;
|
|
62
|
+
}
|
|
63
|
+
/** A channel identifier / login attached to a person identity. */
|
|
64
|
+
interface CapturedIdentityLink {
|
|
65
|
+
id: string;
|
|
66
|
+
identityId: string;
|
|
67
|
+
kind: string;
|
|
68
|
+
externalId: string;
|
|
69
|
+
verified: boolean;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Inspect the simulated platform-identity directory — the in-memory stand-in for
|
|
74
|
+
* the control plane that `client.identities` talks to in tests. Identities and
|
|
75
|
+
* links created by app code under test land here; assert on them like `inbox`.
|
|
76
|
+
*/
|
|
77
|
+
interface TestDirectory {
|
|
78
|
+
/** All identities, oldest first. */
|
|
79
|
+
all(): CapturedIdentity[];
|
|
80
|
+
/** A single identity by id, or undefined. */
|
|
81
|
+
get(id: string): CapturedIdentity | undefined;
|
|
82
|
+
/** Channel/login links attached to an identity. */
|
|
83
|
+
links(identityId: string): CapturedIdentityLink[];
|
|
84
|
+
clear(): void;
|
|
85
|
+
get count(): number;
|
|
86
|
+
}
|
|
46
87
|
interface TestAppOptions {
|
|
47
88
|
/**
|
|
48
89
|
* Path to the schema.sql snapshot generated by
|
|
@@ -70,6 +111,8 @@ interface TestApp {
|
|
|
70
111
|
db: PGlite;
|
|
71
112
|
/** Captured outbound email. */
|
|
72
113
|
inbox: TestInbox;
|
|
114
|
+
/** Inspect the simulated platform-identity directory (`client.identities`). */
|
|
115
|
+
identities: TestDirectory;
|
|
73
116
|
/** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
|
|
74
117
|
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
75
118
|
/**
|
|
@@ -140,4 +183,4 @@ declare const TEST_ENV_DEFAULTS: {
|
|
|
140
183
|
/** Default location of the DDL snapshot written by `generate-types`. */
|
|
141
184
|
declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
142
185
|
|
|
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 };
|
|
186
|
+
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
|
package/dist/index.d.ts
CHANGED
|
@@ -43,6 +43,47 @@ interface TestInbox {
|
|
|
43
43
|
clear(): void;
|
|
44
44
|
get count(): number;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* A platform identity captured by the simulated directory. Mirrors the `Identity`
|
|
48
|
+
* shape returned by @stardeck-customer-apps/integrations-sdk's `client.identities`
|
|
49
|
+
* — keep in sync.
|
|
50
|
+
*/
|
|
51
|
+
interface CapturedIdentity {
|
|
52
|
+
id: string;
|
|
53
|
+
type: "person" | "account";
|
|
54
|
+
parentId: string | null;
|
|
55
|
+
displayName: string | null;
|
|
56
|
+
profile: Record<string, unknown>;
|
|
57
|
+
status: "active" | "merged" | "archived";
|
|
58
|
+
mergedIntoId: string | null;
|
|
59
|
+
externalRef: string | null;
|
|
60
|
+
createdAt: string;
|
|
61
|
+
updatedAt: string;
|
|
62
|
+
}
|
|
63
|
+
/** A channel identifier / login attached to a person identity. */
|
|
64
|
+
interface CapturedIdentityLink {
|
|
65
|
+
id: string;
|
|
66
|
+
identityId: string;
|
|
67
|
+
kind: string;
|
|
68
|
+
externalId: string;
|
|
69
|
+
verified: boolean;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Inspect the simulated platform-identity directory — the in-memory stand-in for
|
|
74
|
+
* the control plane that `client.identities` talks to in tests. Identities and
|
|
75
|
+
* links created by app code under test land here; assert on them like `inbox`.
|
|
76
|
+
*/
|
|
77
|
+
interface TestDirectory {
|
|
78
|
+
/** All identities, oldest first. */
|
|
79
|
+
all(): CapturedIdentity[];
|
|
80
|
+
/** A single identity by id, or undefined. */
|
|
81
|
+
get(id: string): CapturedIdentity | undefined;
|
|
82
|
+
/** Channel/login links attached to an identity. */
|
|
83
|
+
links(identityId: string): CapturedIdentityLink[];
|
|
84
|
+
clear(): void;
|
|
85
|
+
get count(): number;
|
|
86
|
+
}
|
|
46
87
|
interface TestAppOptions {
|
|
47
88
|
/**
|
|
48
89
|
* Path to the schema.sql snapshot generated by
|
|
@@ -70,6 +111,8 @@ interface TestApp {
|
|
|
70
111
|
db: PGlite;
|
|
71
112
|
/** Captured outbound email. */
|
|
72
113
|
inbox: TestInbox;
|
|
114
|
+
/** Inspect the simulated platform-identity directory (`client.identities`). */
|
|
115
|
+
identities: TestDirectory;
|
|
73
116
|
/** Convenience for raw SQL: `app.query("SELECT ...", [param])`. */
|
|
74
117
|
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
75
118
|
/**
|
|
@@ -140,4 +183,4 @@ declare const TEST_ENV_DEFAULTS: {
|
|
|
140
183
|
/** Default location of the DDL snapshot written by `generate-types`. */
|
|
141
184
|
declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
|
|
142
185
|
|
|
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 };
|
|
186
|
+
export { CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type SessionTokens, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestInbox, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
|
package/dist/index.js
CHANGED
|
@@ -43,7 +43,7 @@ __export(index_exports, {
|
|
|
43
43
|
module.exports = __toCommonJS(index_exports);
|
|
44
44
|
|
|
45
45
|
// src/test-app.ts
|
|
46
|
-
var
|
|
46
|
+
var import_node_crypto4 = __toESM(require("crypto"));
|
|
47
47
|
var import_node_fs = require("fs");
|
|
48
48
|
var import_node_path = require("path");
|
|
49
49
|
|
|
@@ -96,6 +96,8 @@ var state = globalSingleton("state", () => ({
|
|
|
96
96
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
97
97
|
emails: [],
|
|
98
98
|
emailCounter: 0,
|
|
99
|
+
identities: /* @__PURE__ */ new Map(),
|
|
100
|
+
identityLinks: [],
|
|
99
101
|
allowNetwork: false
|
|
100
102
|
}));
|
|
101
103
|
function requireDb() {
|
|
@@ -155,8 +157,8 @@ function verifyDeploymentAuthHeader(secret, header) {
|
|
|
155
157
|
return null;
|
|
156
158
|
}
|
|
157
159
|
if (payload.type !== "deployment-request") return null;
|
|
158
|
-
const
|
|
159
|
-
if (Math.abs(
|
|
160
|
+
const now2 = Math.floor(Date.now() / 1e3);
|
|
161
|
+
if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
|
|
160
162
|
return payload;
|
|
161
163
|
}
|
|
162
164
|
|
|
@@ -460,6 +462,7 @@ var FIELD_TYPE_TO_PG = {
|
|
|
460
462
|
currency: "numeric",
|
|
461
463
|
rating: "integer",
|
|
462
464
|
relation: "uuid",
|
|
465
|
+
identity: "uuid",
|
|
463
466
|
file_ref: "jsonb",
|
|
464
467
|
file_refs: "jsonb",
|
|
465
468
|
json: "jsonb"
|
|
@@ -637,6 +640,153 @@ function createInbox() {
|
|
|
637
640
|
};
|
|
638
641
|
}
|
|
639
642
|
|
|
643
|
+
// src/simulator/identities.ts
|
|
644
|
+
var import_node_crypto3 = __toESM(require("crypto"));
|
|
645
|
+
var LINK_KINDS = /* @__PURE__ */ new Set([
|
|
646
|
+
"line",
|
|
647
|
+
"facebook",
|
|
648
|
+
"instagram",
|
|
649
|
+
"email",
|
|
650
|
+
"phone",
|
|
651
|
+
"project_auth_user",
|
|
652
|
+
"dashboard_user"
|
|
653
|
+
]);
|
|
654
|
+
function now() {
|
|
655
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
656
|
+
}
|
|
657
|
+
function linksFor(identityId) {
|
|
658
|
+
return state.identityLinks.filter((l) => l.identityId === identityId);
|
|
659
|
+
}
|
|
660
|
+
async function readBody(request) {
|
|
661
|
+
try {
|
|
662
|
+
return await request.json();
|
|
663
|
+
} catch {
|
|
664
|
+
return {};
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function handleList(request) {
|
|
668
|
+
const typeParam = new URL(request.url).searchParams.get("type");
|
|
669
|
+
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
670
|
+
const identities = [...state.identities.values()];
|
|
671
|
+
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
672
|
+
}
|
|
673
|
+
async function handleCreate(request) {
|
|
674
|
+
const body = await readBody(request);
|
|
675
|
+
const type = body.type;
|
|
676
|
+
if (type !== "person" && type !== "account") {
|
|
677
|
+
return failure("type must be 'person' or 'account'");
|
|
678
|
+
}
|
|
679
|
+
let parentId = null;
|
|
680
|
+
if (type === "person" && body.parentId) {
|
|
681
|
+
const parent = state.identities.get(body.parentId);
|
|
682
|
+
if (!parent) return failure("parent identity not found");
|
|
683
|
+
if (parent.type !== "account") {
|
|
684
|
+
return failure("a person's parent must be an account");
|
|
685
|
+
}
|
|
686
|
+
parentId = parent.id;
|
|
687
|
+
}
|
|
688
|
+
const identity = {
|
|
689
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
690
|
+
type,
|
|
691
|
+
parentId,
|
|
692
|
+
displayName: body.displayName ?? null,
|
|
693
|
+
profile: body.profile ?? {},
|
|
694
|
+
status: "active",
|
|
695
|
+
mergedIntoId: null,
|
|
696
|
+
externalRef: body.externalRef ?? null,
|
|
697
|
+
createdAt: now(),
|
|
698
|
+
updatedAt: now()
|
|
699
|
+
};
|
|
700
|
+
state.identities.set(identity.id, identity);
|
|
701
|
+
return success({ identity });
|
|
702
|
+
}
|
|
703
|
+
function handleGet(identityId) {
|
|
704
|
+
const identity = state.identities.get(identityId);
|
|
705
|
+
if (!identity) return failure("identity not found", 404);
|
|
706
|
+
return success({ identity, links: linksFor(identityId) });
|
|
707
|
+
}
|
|
708
|
+
async function handleUpdate(identityId, request) {
|
|
709
|
+
const identity = state.identities.get(identityId);
|
|
710
|
+
if (!identity) return failure("identity not found", 404);
|
|
711
|
+
const body = await readBody(request);
|
|
712
|
+
if (body.displayName !== void 0) {
|
|
713
|
+
identity.displayName = body.displayName;
|
|
714
|
+
}
|
|
715
|
+
if (body.profile !== void 0) {
|
|
716
|
+
identity.profile = body.profile;
|
|
717
|
+
}
|
|
718
|
+
if (body.externalRef !== void 0) {
|
|
719
|
+
identity.externalRef = body.externalRef;
|
|
720
|
+
}
|
|
721
|
+
identity.updatedAt = now();
|
|
722
|
+
return success({ identity });
|
|
723
|
+
}
|
|
724
|
+
async function handleAttachLink(identityId, request) {
|
|
725
|
+
const identity = state.identities.get(identityId);
|
|
726
|
+
if (!identity) return failure("identity not found", 404);
|
|
727
|
+
if (identity.type !== "person") {
|
|
728
|
+
return failure("links attach only to a person identity");
|
|
729
|
+
}
|
|
730
|
+
if (identity.status !== "active") {
|
|
731
|
+
return failure("links attach only to active persons");
|
|
732
|
+
}
|
|
733
|
+
const body = await readBody(request);
|
|
734
|
+
const kind = body.kind;
|
|
735
|
+
const externalId = body.externalId;
|
|
736
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
737
|
+
return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
738
|
+
}
|
|
739
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
740
|
+
return failure("externalId is required");
|
|
741
|
+
}
|
|
742
|
+
const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
|
|
743
|
+
if (existing) {
|
|
744
|
+
if (existing.identityId === identityId) return success({ link: existing });
|
|
745
|
+
return failure("identifier already linked to another identity", 409);
|
|
746
|
+
}
|
|
747
|
+
const link = {
|
|
748
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
749
|
+
identityId,
|
|
750
|
+
kind,
|
|
751
|
+
externalId,
|
|
752
|
+
verified: body.verified === true,
|
|
753
|
+
createdAt: now()
|
|
754
|
+
};
|
|
755
|
+
state.identityLinks.push(link);
|
|
756
|
+
return success({ link });
|
|
757
|
+
}
|
|
758
|
+
async function handleIdentitiesRequest(request, subPath) {
|
|
759
|
+
const method = request.method;
|
|
760
|
+
if (subPath === "" || subPath === "/") {
|
|
761
|
+
if (method === "GET") return handleList(request);
|
|
762
|
+
if (method === "POST") return handleCreate(request);
|
|
763
|
+
}
|
|
764
|
+
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
765
|
+
if (linksMatch && method === "POST") {
|
|
766
|
+
return handleAttachLink(linksMatch[1], request);
|
|
767
|
+
}
|
|
768
|
+
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
769
|
+
if (singleMatch) {
|
|
770
|
+
if (method === "GET") return handleGet(singleMatch[1]);
|
|
771
|
+
if (method === "PATCH") return handleUpdate(singleMatch[1], request);
|
|
772
|
+
}
|
|
773
|
+
return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
|
|
774
|
+
}
|
|
775
|
+
function createDirectory() {
|
|
776
|
+
return {
|
|
777
|
+
all: () => [...state.identities.values()],
|
|
778
|
+
get: (id) => state.identities.get(id),
|
|
779
|
+
links: (identityId) => linksFor(identityId),
|
|
780
|
+
clear: () => {
|
|
781
|
+
state.identities.clear();
|
|
782
|
+
state.identityLinks = [];
|
|
783
|
+
},
|
|
784
|
+
get count() {
|
|
785
|
+
return state.identities.size;
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
640
790
|
// src/simulator/router.ts
|
|
641
791
|
var fetchHolder = globalSingleton("fetch-holder", () => ({
|
|
642
792
|
originalFetch: null
|
|
@@ -654,7 +804,8 @@ async function handleSimulatedRequest(request, url) {
|
|
|
654
804
|
}
|
|
655
805
|
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
656
806
|
const isEmail = url.pathname === "/api/email/send";
|
|
657
|
-
|
|
807
|
+
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
808
|
+
if (dataStoreMatch || isEmail || identitiesMatch) {
|
|
658
809
|
const authHeader = request.headers.get("X-Stardeck-Auth");
|
|
659
810
|
if (!authHeader) {
|
|
660
811
|
return failure("Missing authentication header", 401);
|
|
@@ -667,28 +818,31 @@ async function handleSimulatedRequest(request, url) {
|
|
|
667
818
|
if (isEmail && request.method === "POST") {
|
|
668
819
|
return handleEmailSend(request);
|
|
669
820
|
}
|
|
821
|
+
if (identitiesMatch) {
|
|
822
|
+
return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
|
|
823
|
+
}
|
|
670
824
|
if (dataStoreMatch) {
|
|
671
825
|
const subPath = dataStoreMatch[1] ?? "";
|
|
672
826
|
const db = requireDb();
|
|
673
|
-
const
|
|
827
|
+
const readBody2 = async () => await request.json();
|
|
674
828
|
if (subPath === "/query" && request.method === "POST") {
|
|
675
|
-
return handleQuery(db, await
|
|
829
|
+
return handleQuery(db, await readBody2());
|
|
676
830
|
}
|
|
677
831
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
678
|
-
return handleMutate(db, await
|
|
832
|
+
return handleMutate(db, await readBody2());
|
|
679
833
|
}
|
|
680
834
|
if (subPath === "/schema" && request.method === "GET") {
|
|
681
835
|
return handleGetSchema(db);
|
|
682
836
|
}
|
|
683
837
|
if (subPath === "/schema/tables" && request.method === "POST") {
|
|
684
|
-
return handleCreateTable(db, await
|
|
838
|
+
return handleCreateTable(db, await readBody2());
|
|
685
839
|
}
|
|
686
840
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
687
|
-
return handleAddColumn(db, await
|
|
841
|
+
return handleAddColumn(db, await readBody2());
|
|
688
842
|
}
|
|
689
843
|
}
|
|
690
844
|
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.`,
|
|
845
|
+
`[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
846
|
404
|
|
693
847
|
);
|
|
694
848
|
}
|
|
@@ -773,9 +927,11 @@ async function createTestApp(options = {}) {
|
|
|
773
927
|
throw error;
|
|
774
928
|
}
|
|
775
929
|
const inbox = createInbox();
|
|
930
|
+
const directory = createDirectory();
|
|
776
931
|
const app = {
|
|
777
932
|
db,
|
|
778
933
|
inbox,
|
|
934
|
+
identities: directory,
|
|
779
935
|
async query(sql, params = []) {
|
|
780
936
|
const result = await db.query(sql, params);
|
|
781
937
|
return result.rows;
|
|
@@ -791,8 +947,8 @@ async function createTestApp(options = {}) {
|
|
|
791
947
|
issueSession(user) {
|
|
792
948
|
const fullUser = buildUser(user);
|
|
793
949
|
const tokens = {
|
|
794
|
-
accessToken: `test-access-${
|
|
795
|
-
refreshToken: `test-refresh-${
|
|
950
|
+
accessToken: `test-access-${import_node_crypto4.default.randomUUID()}`,
|
|
951
|
+
refreshToken: `test-refresh-${import_node_crypto4.default.randomUUID()}`
|
|
796
952
|
};
|
|
797
953
|
state.sessions.set(tokens.accessToken, fullUser);
|
|
798
954
|
state.refreshSessions.set(tokens.refreshToken, fullUser);
|
|
@@ -806,6 +962,8 @@ async function createTestApp(options = {}) {
|
|
|
806
962
|
state.refreshSessions.clear();
|
|
807
963
|
state.emails = [];
|
|
808
964
|
state.emailCounter = 0;
|
|
965
|
+
state.identities.clear();
|
|
966
|
+
state.identityLinks = [];
|
|
809
967
|
},
|
|
810
968
|
async close() {
|
|
811
969
|
state.db = null;
|
|
@@ -813,6 +971,8 @@ async function createTestApp(options = {}) {
|
|
|
813
971
|
state.sessions.clear();
|
|
814
972
|
state.refreshSessions.clear();
|
|
815
973
|
state.emails = [];
|
|
974
|
+
state.identities.clear();
|
|
975
|
+
state.identityLinks = [];
|
|
816
976
|
uninstallFetchRouter();
|
|
817
977
|
await db.close();
|
|
818
978
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/test-app.ts
|
|
2
|
-
import
|
|
2
|
+
import crypto4 from "crypto";
|
|
3
3
|
import { readFileSync, existsSync } from "fs";
|
|
4
4
|
import { resolve } from "path";
|
|
5
5
|
|
|
@@ -52,6 +52,8 @@ var state = globalSingleton("state", () => ({
|
|
|
52
52
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
53
53
|
emails: [],
|
|
54
54
|
emailCounter: 0,
|
|
55
|
+
identities: /* @__PURE__ */ new Map(),
|
|
56
|
+
identityLinks: [],
|
|
55
57
|
allowNetwork: false
|
|
56
58
|
}));
|
|
57
59
|
function requireDb() {
|
|
@@ -111,8 +113,8 @@ function verifyDeploymentAuthHeader(secret, header) {
|
|
|
111
113
|
return null;
|
|
112
114
|
}
|
|
113
115
|
if (payload.type !== "deployment-request") return null;
|
|
114
|
-
const
|
|
115
|
-
if (Math.abs(
|
|
116
|
+
const now2 = Math.floor(Date.now() / 1e3);
|
|
117
|
+
if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
|
|
116
118
|
return payload;
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -416,6 +418,7 @@ var FIELD_TYPE_TO_PG = {
|
|
|
416
418
|
currency: "numeric",
|
|
417
419
|
rating: "integer",
|
|
418
420
|
relation: "uuid",
|
|
421
|
+
identity: "uuid",
|
|
419
422
|
file_ref: "jsonb",
|
|
420
423
|
file_refs: "jsonb",
|
|
421
424
|
json: "jsonb"
|
|
@@ -593,6 +596,153 @@ function createInbox() {
|
|
|
593
596
|
};
|
|
594
597
|
}
|
|
595
598
|
|
|
599
|
+
// src/simulator/identities.ts
|
|
600
|
+
import crypto3 from "crypto";
|
|
601
|
+
var LINK_KINDS = /* @__PURE__ */ new Set([
|
|
602
|
+
"line",
|
|
603
|
+
"facebook",
|
|
604
|
+
"instagram",
|
|
605
|
+
"email",
|
|
606
|
+
"phone",
|
|
607
|
+
"project_auth_user",
|
|
608
|
+
"dashboard_user"
|
|
609
|
+
]);
|
|
610
|
+
function now() {
|
|
611
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
612
|
+
}
|
|
613
|
+
function linksFor(identityId) {
|
|
614
|
+
return state.identityLinks.filter((l) => l.identityId === identityId);
|
|
615
|
+
}
|
|
616
|
+
async function readBody(request) {
|
|
617
|
+
try {
|
|
618
|
+
return await request.json();
|
|
619
|
+
} catch {
|
|
620
|
+
return {};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function handleList(request) {
|
|
624
|
+
const typeParam = new URL(request.url).searchParams.get("type");
|
|
625
|
+
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
626
|
+
const identities = [...state.identities.values()];
|
|
627
|
+
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
628
|
+
}
|
|
629
|
+
async function handleCreate(request) {
|
|
630
|
+
const body = await readBody(request);
|
|
631
|
+
const type = body.type;
|
|
632
|
+
if (type !== "person" && type !== "account") {
|
|
633
|
+
return failure("type must be 'person' or 'account'");
|
|
634
|
+
}
|
|
635
|
+
let parentId = null;
|
|
636
|
+
if (type === "person" && body.parentId) {
|
|
637
|
+
const parent = state.identities.get(body.parentId);
|
|
638
|
+
if (!parent) return failure("parent identity not found");
|
|
639
|
+
if (parent.type !== "account") {
|
|
640
|
+
return failure("a person's parent must be an account");
|
|
641
|
+
}
|
|
642
|
+
parentId = parent.id;
|
|
643
|
+
}
|
|
644
|
+
const identity = {
|
|
645
|
+
id: crypto3.randomUUID(),
|
|
646
|
+
type,
|
|
647
|
+
parentId,
|
|
648
|
+
displayName: body.displayName ?? null,
|
|
649
|
+
profile: body.profile ?? {},
|
|
650
|
+
status: "active",
|
|
651
|
+
mergedIntoId: null,
|
|
652
|
+
externalRef: body.externalRef ?? null,
|
|
653
|
+
createdAt: now(),
|
|
654
|
+
updatedAt: now()
|
|
655
|
+
};
|
|
656
|
+
state.identities.set(identity.id, identity);
|
|
657
|
+
return success({ identity });
|
|
658
|
+
}
|
|
659
|
+
function handleGet(identityId) {
|
|
660
|
+
const identity = state.identities.get(identityId);
|
|
661
|
+
if (!identity) return failure("identity not found", 404);
|
|
662
|
+
return success({ identity, links: linksFor(identityId) });
|
|
663
|
+
}
|
|
664
|
+
async function handleUpdate(identityId, request) {
|
|
665
|
+
const identity = state.identities.get(identityId);
|
|
666
|
+
if (!identity) return failure("identity not found", 404);
|
|
667
|
+
const body = await readBody(request);
|
|
668
|
+
if (body.displayName !== void 0) {
|
|
669
|
+
identity.displayName = body.displayName;
|
|
670
|
+
}
|
|
671
|
+
if (body.profile !== void 0) {
|
|
672
|
+
identity.profile = body.profile;
|
|
673
|
+
}
|
|
674
|
+
if (body.externalRef !== void 0) {
|
|
675
|
+
identity.externalRef = body.externalRef;
|
|
676
|
+
}
|
|
677
|
+
identity.updatedAt = now();
|
|
678
|
+
return success({ identity });
|
|
679
|
+
}
|
|
680
|
+
async function handleAttachLink(identityId, request) {
|
|
681
|
+
const identity = state.identities.get(identityId);
|
|
682
|
+
if (!identity) return failure("identity not found", 404);
|
|
683
|
+
if (identity.type !== "person") {
|
|
684
|
+
return failure("links attach only to a person identity");
|
|
685
|
+
}
|
|
686
|
+
if (identity.status !== "active") {
|
|
687
|
+
return failure("links attach only to active persons");
|
|
688
|
+
}
|
|
689
|
+
const body = await readBody(request);
|
|
690
|
+
const kind = body.kind;
|
|
691
|
+
const externalId = body.externalId;
|
|
692
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
693
|
+
return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
694
|
+
}
|
|
695
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
696
|
+
return failure("externalId is required");
|
|
697
|
+
}
|
|
698
|
+
const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
|
|
699
|
+
if (existing) {
|
|
700
|
+
if (existing.identityId === identityId) return success({ link: existing });
|
|
701
|
+
return failure("identifier already linked to another identity", 409);
|
|
702
|
+
}
|
|
703
|
+
const link = {
|
|
704
|
+
id: crypto3.randomUUID(),
|
|
705
|
+
identityId,
|
|
706
|
+
kind,
|
|
707
|
+
externalId,
|
|
708
|
+
verified: body.verified === true,
|
|
709
|
+
createdAt: now()
|
|
710
|
+
};
|
|
711
|
+
state.identityLinks.push(link);
|
|
712
|
+
return success({ link });
|
|
713
|
+
}
|
|
714
|
+
async function handleIdentitiesRequest(request, subPath) {
|
|
715
|
+
const method = request.method;
|
|
716
|
+
if (subPath === "" || subPath === "/") {
|
|
717
|
+
if (method === "GET") return handleList(request);
|
|
718
|
+
if (method === "POST") return handleCreate(request);
|
|
719
|
+
}
|
|
720
|
+
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
721
|
+
if (linksMatch && method === "POST") {
|
|
722
|
+
return handleAttachLink(linksMatch[1], request);
|
|
723
|
+
}
|
|
724
|
+
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
725
|
+
if (singleMatch) {
|
|
726
|
+
if (method === "GET") return handleGet(singleMatch[1]);
|
|
727
|
+
if (method === "PATCH") return handleUpdate(singleMatch[1], request);
|
|
728
|
+
}
|
|
729
|
+
return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
|
|
730
|
+
}
|
|
731
|
+
function createDirectory() {
|
|
732
|
+
return {
|
|
733
|
+
all: () => [...state.identities.values()],
|
|
734
|
+
get: (id) => state.identities.get(id),
|
|
735
|
+
links: (identityId) => linksFor(identityId),
|
|
736
|
+
clear: () => {
|
|
737
|
+
state.identities.clear();
|
|
738
|
+
state.identityLinks = [];
|
|
739
|
+
},
|
|
740
|
+
get count() {
|
|
741
|
+
return state.identities.size;
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
596
746
|
// src/simulator/router.ts
|
|
597
747
|
var fetchHolder = globalSingleton("fetch-holder", () => ({
|
|
598
748
|
originalFetch: null
|
|
@@ -610,7 +760,8 @@ async function handleSimulatedRequest(request, url) {
|
|
|
610
760
|
}
|
|
611
761
|
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
612
762
|
const isEmail = url.pathname === "/api/email/send";
|
|
613
|
-
|
|
763
|
+
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
764
|
+
if (dataStoreMatch || isEmail || identitiesMatch) {
|
|
614
765
|
const authHeader = request.headers.get("X-Stardeck-Auth");
|
|
615
766
|
if (!authHeader) {
|
|
616
767
|
return failure("Missing authentication header", 401);
|
|
@@ -623,28 +774,31 @@ async function handleSimulatedRequest(request, url) {
|
|
|
623
774
|
if (isEmail && request.method === "POST") {
|
|
624
775
|
return handleEmailSend(request);
|
|
625
776
|
}
|
|
777
|
+
if (identitiesMatch) {
|
|
778
|
+
return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
|
|
779
|
+
}
|
|
626
780
|
if (dataStoreMatch) {
|
|
627
781
|
const subPath = dataStoreMatch[1] ?? "";
|
|
628
782
|
const db = requireDb();
|
|
629
|
-
const
|
|
783
|
+
const readBody2 = async () => await request.json();
|
|
630
784
|
if (subPath === "/query" && request.method === "POST") {
|
|
631
|
-
return handleQuery(db, await
|
|
785
|
+
return handleQuery(db, await readBody2());
|
|
632
786
|
}
|
|
633
787
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
634
|
-
return handleMutate(db, await
|
|
788
|
+
return handleMutate(db, await readBody2());
|
|
635
789
|
}
|
|
636
790
|
if (subPath === "/schema" && request.method === "GET") {
|
|
637
791
|
return handleGetSchema(db);
|
|
638
792
|
}
|
|
639
793
|
if (subPath === "/schema/tables" && request.method === "POST") {
|
|
640
|
-
return handleCreateTable(db, await
|
|
794
|
+
return handleCreateTable(db, await readBody2());
|
|
641
795
|
}
|
|
642
796
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
643
|
-
return handleAddColumn(db, await
|
|
797
|
+
return handleAddColumn(db, await readBody2());
|
|
644
798
|
}
|
|
645
799
|
}
|
|
646
800
|
return failure(
|
|
647
|
-
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
|
|
801
|
+
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
|
|
648
802
|
404
|
|
649
803
|
);
|
|
650
804
|
}
|
|
@@ -729,9 +883,11 @@ async function createTestApp(options = {}) {
|
|
|
729
883
|
throw error;
|
|
730
884
|
}
|
|
731
885
|
const inbox = createInbox();
|
|
886
|
+
const directory = createDirectory();
|
|
732
887
|
const app = {
|
|
733
888
|
db,
|
|
734
889
|
inbox,
|
|
890
|
+
identities: directory,
|
|
735
891
|
async query(sql, params = []) {
|
|
736
892
|
const result = await db.query(sql, params);
|
|
737
893
|
return result.rows;
|
|
@@ -747,8 +903,8 @@ async function createTestApp(options = {}) {
|
|
|
747
903
|
issueSession(user) {
|
|
748
904
|
const fullUser = buildUser(user);
|
|
749
905
|
const tokens = {
|
|
750
|
-
accessToken: `test-access-${
|
|
751
|
-
refreshToken: `test-refresh-${
|
|
906
|
+
accessToken: `test-access-${crypto4.randomUUID()}`,
|
|
907
|
+
refreshToken: `test-refresh-${crypto4.randomUUID()}`
|
|
752
908
|
};
|
|
753
909
|
state.sessions.set(tokens.accessToken, fullUser);
|
|
754
910
|
state.refreshSessions.set(tokens.refreshToken, fullUser);
|
|
@@ -762,6 +918,8 @@ async function createTestApp(options = {}) {
|
|
|
762
918
|
state.refreshSessions.clear();
|
|
763
919
|
state.emails = [];
|
|
764
920
|
state.emailCounter = 0;
|
|
921
|
+
state.identities.clear();
|
|
922
|
+
state.identityLinks = [];
|
|
765
923
|
},
|
|
766
924
|
async close() {
|
|
767
925
|
state.db = null;
|
|
@@ -769,6 +927,8 @@ async function createTestApp(options = {}) {
|
|
|
769
927
|
state.sessions.clear();
|
|
770
928
|
state.refreshSessions.clear();
|
|
771
929
|
state.emails = [];
|
|
930
|
+
state.identities.clear();
|
|
931
|
+
state.identityLinks = [];
|
|
772
932
|
uninstallFetchRouter();
|
|
773
933
|
await db.close();
|
|
774
934
|
}
|
package/dist/setup.js
CHANGED
|
@@ -38,6 +38,8 @@ var state = globalSingleton("state", () => ({
|
|
|
38
38
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
39
39
|
emails: [],
|
|
40
40
|
emailCounter: 0,
|
|
41
|
+
identities: /* @__PURE__ */ new Map(),
|
|
42
|
+
identityLinks: [],
|
|
41
43
|
allowNetwork: false
|
|
42
44
|
}));
|
|
43
45
|
function requireDb() {
|
|
@@ -89,8 +91,8 @@ function verifyDeploymentAuthHeader(secret, header) {
|
|
|
89
91
|
return null;
|
|
90
92
|
}
|
|
91
93
|
if (payload.type !== "deployment-request") return null;
|
|
92
|
-
const
|
|
93
|
-
if (Math.abs(
|
|
94
|
+
const now2 = Math.floor(Date.now() / 1e3);
|
|
95
|
+
if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
|
|
94
96
|
return payload;
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -394,6 +396,7 @@ var FIELD_TYPE_TO_PG = {
|
|
|
394
396
|
currency: "numeric",
|
|
395
397
|
rating: "integer",
|
|
396
398
|
relation: "uuid",
|
|
399
|
+
identity: "uuid",
|
|
397
400
|
file_ref: "jsonb",
|
|
398
401
|
file_refs: "jsonb",
|
|
399
402
|
json: "jsonb"
|
|
@@ -569,6 +572,139 @@ async function handleEmailSend(request) {
|
|
|
569
572
|
return success({ resendId, fromAddress });
|
|
570
573
|
}
|
|
571
574
|
|
|
575
|
+
// src/simulator/identities.ts
|
|
576
|
+
var import_node_crypto3 = __toESM(require("crypto"));
|
|
577
|
+
var LINK_KINDS = /* @__PURE__ */ new Set([
|
|
578
|
+
"line",
|
|
579
|
+
"facebook",
|
|
580
|
+
"instagram",
|
|
581
|
+
"email",
|
|
582
|
+
"phone",
|
|
583
|
+
"project_auth_user",
|
|
584
|
+
"dashboard_user"
|
|
585
|
+
]);
|
|
586
|
+
function now() {
|
|
587
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
588
|
+
}
|
|
589
|
+
function linksFor(identityId) {
|
|
590
|
+
return state.identityLinks.filter((l) => l.identityId === identityId);
|
|
591
|
+
}
|
|
592
|
+
async function readBody(request) {
|
|
593
|
+
try {
|
|
594
|
+
return await request.json();
|
|
595
|
+
} catch {
|
|
596
|
+
return {};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function handleList(request) {
|
|
600
|
+
const typeParam = new URL(request.url).searchParams.get("type");
|
|
601
|
+
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
602
|
+
const identities = [...state.identities.values()];
|
|
603
|
+
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
604
|
+
}
|
|
605
|
+
async function handleCreate(request) {
|
|
606
|
+
const body = await readBody(request);
|
|
607
|
+
const type = body.type;
|
|
608
|
+
if (type !== "person" && type !== "account") {
|
|
609
|
+
return failure("type must be 'person' or 'account'");
|
|
610
|
+
}
|
|
611
|
+
let parentId = null;
|
|
612
|
+
if (type === "person" && body.parentId) {
|
|
613
|
+
const parent = state.identities.get(body.parentId);
|
|
614
|
+
if (!parent) return failure("parent identity not found");
|
|
615
|
+
if (parent.type !== "account") {
|
|
616
|
+
return failure("a person's parent must be an account");
|
|
617
|
+
}
|
|
618
|
+
parentId = parent.id;
|
|
619
|
+
}
|
|
620
|
+
const identity = {
|
|
621
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
622
|
+
type,
|
|
623
|
+
parentId,
|
|
624
|
+
displayName: body.displayName ?? null,
|
|
625
|
+
profile: body.profile ?? {},
|
|
626
|
+
status: "active",
|
|
627
|
+
mergedIntoId: null,
|
|
628
|
+
externalRef: body.externalRef ?? null,
|
|
629
|
+
createdAt: now(),
|
|
630
|
+
updatedAt: now()
|
|
631
|
+
};
|
|
632
|
+
state.identities.set(identity.id, identity);
|
|
633
|
+
return success({ identity });
|
|
634
|
+
}
|
|
635
|
+
function handleGet(identityId) {
|
|
636
|
+
const identity = state.identities.get(identityId);
|
|
637
|
+
if (!identity) return failure("identity not found", 404);
|
|
638
|
+
return success({ identity, links: linksFor(identityId) });
|
|
639
|
+
}
|
|
640
|
+
async function handleUpdate(identityId, request) {
|
|
641
|
+
const identity = state.identities.get(identityId);
|
|
642
|
+
if (!identity) return failure("identity not found", 404);
|
|
643
|
+
const body = await readBody(request);
|
|
644
|
+
if (body.displayName !== void 0) {
|
|
645
|
+
identity.displayName = body.displayName;
|
|
646
|
+
}
|
|
647
|
+
if (body.profile !== void 0) {
|
|
648
|
+
identity.profile = body.profile;
|
|
649
|
+
}
|
|
650
|
+
if (body.externalRef !== void 0) {
|
|
651
|
+
identity.externalRef = body.externalRef;
|
|
652
|
+
}
|
|
653
|
+
identity.updatedAt = now();
|
|
654
|
+
return success({ identity });
|
|
655
|
+
}
|
|
656
|
+
async function handleAttachLink(identityId, request) {
|
|
657
|
+
const identity = state.identities.get(identityId);
|
|
658
|
+
if (!identity) return failure("identity not found", 404);
|
|
659
|
+
if (identity.type !== "person") {
|
|
660
|
+
return failure("links attach only to a person identity");
|
|
661
|
+
}
|
|
662
|
+
if (identity.status !== "active") {
|
|
663
|
+
return failure("links attach only to active persons");
|
|
664
|
+
}
|
|
665
|
+
const body = await readBody(request);
|
|
666
|
+
const kind = body.kind;
|
|
667
|
+
const externalId = body.externalId;
|
|
668
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
669
|
+
return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
670
|
+
}
|
|
671
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
672
|
+
return failure("externalId is required");
|
|
673
|
+
}
|
|
674
|
+
const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
|
|
675
|
+
if (existing) {
|
|
676
|
+
if (existing.identityId === identityId) return success({ link: existing });
|
|
677
|
+
return failure("identifier already linked to another identity", 409);
|
|
678
|
+
}
|
|
679
|
+
const link = {
|
|
680
|
+
id: import_node_crypto3.default.randomUUID(),
|
|
681
|
+
identityId,
|
|
682
|
+
kind,
|
|
683
|
+
externalId,
|
|
684
|
+
verified: body.verified === true,
|
|
685
|
+
createdAt: now()
|
|
686
|
+
};
|
|
687
|
+
state.identityLinks.push(link);
|
|
688
|
+
return success({ link });
|
|
689
|
+
}
|
|
690
|
+
async function handleIdentitiesRequest(request, subPath) {
|
|
691
|
+
const method = request.method;
|
|
692
|
+
if (subPath === "" || subPath === "/") {
|
|
693
|
+
if (method === "GET") return handleList(request);
|
|
694
|
+
if (method === "POST") return handleCreate(request);
|
|
695
|
+
}
|
|
696
|
+
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
697
|
+
if (linksMatch && method === "POST") {
|
|
698
|
+
return handleAttachLink(linksMatch[1], request);
|
|
699
|
+
}
|
|
700
|
+
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
701
|
+
if (singleMatch) {
|
|
702
|
+
if (method === "GET") return handleGet(singleMatch[1]);
|
|
703
|
+
if (method === "PATCH") return handleUpdate(singleMatch[1], request);
|
|
704
|
+
}
|
|
705
|
+
return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
|
|
706
|
+
}
|
|
707
|
+
|
|
572
708
|
// src/simulator/router.ts
|
|
573
709
|
var fetchHolder = globalSingleton("fetch-holder", () => ({
|
|
574
710
|
originalFetch: null
|
|
@@ -586,7 +722,8 @@ async function handleSimulatedRequest(request, url) {
|
|
|
586
722
|
}
|
|
587
723
|
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
588
724
|
const isEmail = url.pathname === "/api/email/send";
|
|
589
|
-
|
|
725
|
+
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
726
|
+
if (dataStoreMatch || isEmail || identitiesMatch) {
|
|
590
727
|
const authHeader = request.headers.get("X-Stardeck-Auth");
|
|
591
728
|
if (!authHeader) {
|
|
592
729
|
return failure("Missing authentication header", 401);
|
|
@@ -599,28 +736,31 @@ async function handleSimulatedRequest(request, url) {
|
|
|
599
736
|
if (isEmail && request.method === "POST") {
|
|
600
737
|
return handleEmailSend(request);
|
|
601
738
|
}
|
|
739
|
+
if (identitiesMatch) {
|
|
740
|
+
return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
|
|
741
|
+
}
|
|
602
742
|
if (dataStoreMatch) {
|
|
603
743
|
const subPath = dataStoreMatch[1] ?? "";
|
|
604
744
|
const db = requireDb();
|
|
605
|
-
const
|
|
745
|
+
const readBody2 = async () => await request.json();
|
|
606
746
|
if (subPath === "/query" && request.method === "POST") {
|
|
607
|
-
return handleQuery(db, await
|
|
747
|
+
return handleQuery(db, await readBody2());
|
|
608
748
|
}
|
|
609
749
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
610
|
-
return handleMutate(db, await
|
|
750
|
+
return handleMutate(db, await readBody2());
|
|
611
751
|
}
|
|
612
752
|
if (subPath === "/schema" && request.method === "GET") {
|
|
613
753
|
return handleGetSchema(db);
|
|
614
754
|
}
|
|
615
755
|
if (subPath === "/schema/tables" && request.method === "POST") {
|
|
616
|
-
return handleCreateTable(db, await
|
|
756
|
+
return handleCreateTable(db, await readBody2());
|
|
617
757
|
}
|
|
618
758
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
619
|
-
return handleAddColumn(db, await
|
|
759
|
+
return handleAddColumn(db, await readBody2());
|
|
620
760
|
}
|
|
621
761
|
}
|
|
622
762
|
return failure(
|
|
623
|
-
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
|
|
763
|
+
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
|
|
624
764
|
404
|
|
625
765
|
);
|
|
626
766
|
}
|
package/dist/setup.mjs
CHANGED
|
@@ -14,6 +14,8 @@ var state = globalSingleton("state", () => ({
|
|
|
14
14
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
15
15
|
emails: [],
|
|
16
16
|
emailCounter: 0,
|
|
17
|
+
identities: /* @__PURE__ */ new Map(),
|
|
18
|
+
identityLinks: [],
|
|
17
19
|
allowNetwork: false
|
|
18
20
|
}));
|
|
19
21
|
function requireDb() {
|
|
@@ -65,8 +67,8 @@ function verifyDeploymentAuthHeader(secret, header) {
|
|
|
65
67
|
return null;
|
|
66
68
|
}
|
|
67
69
|
if (payload.type !== "deployment-request") return null;
|
|
68
|
-
const
|
|
69
|
-
if (Math.abs(
|
|
70
|
+
const now2 = Math.floor(Date.now() / 1e3);
|
|
71
|
+
if (Math.abs(now2 - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
|
|
70
72
|
return payload;
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -370,6 +372,7 @@ var FIELD_TYPE_TO_PG = {
|
|
|
370
372
|
currency: "numeric",
|
|
371
373
|
rating: "integer",
|
|
372
374
|
relation: "uuid",
|
|
375
|
+
identity: "uuid",
|
|
373
376
|
file_ref: "jsonb",
|
|
374
377
|
file_refs: "jsonb",
|
|
375
378
|
json: "jsonb"
|
|
@@ -545,6 +548,139 @@ async function handleEmailSend(request) {
|
|
|
545
548
|
return success({ resendId, fromAddress });
|
|
546
549
|
}
|
|
547
550
|
|
|
551
|
+
// src/simulator/identities.ts
|
|
552
|
+
import crypto3 from "crypto";
|
|
553
|
+
var LINK_KINDS = /* @__PURE__ */ new Set([
|
|
554
|
+
"line",
|
|
555
|
+
"facebook",
|
|
556
|
+
"instagram",
|
|
557
|
+
"email",
|
|
558
|
+
"phone",
|
|
559
|
+
"project_auth_user",
|
|
560
|
+
"dashboard_user"
|
|
561
|
+
]);
|
|
562
|
+
function now() {
|
|
563
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
564
|
+
}
|
|
565
|
+
function linksFor(identityId) {
|
|
566
|
+
return state.identityLinks.filter((l) => l.identityId === identityId);
|
|
567
|
+
}
|
|
568
|
+
async function readBody(request) {
|
|
569
|
+
try {
|
|
570
|
+
return await request.json();
|
|
571
|
+
} catch {
|
|
572
|
+
return {};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function handleList(request) {
|
|
576
|
+
const typeParam = new URL(request.url).searchParams.get("type");
|
|
577
|
+
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
578
|
+
const identities = [...state.identities.values()];
|
|
579
|
+
return success({ identities: type ? identities.filter((i) => i.type === type) : identities });
|
|
580
|
+
}
|
|
581
|
+
async function handleCreate(request) {
|
|
582
|
+
const body = await readBody(request);
|
|
583
|
+
const type = body.type;
|
|
584
|
+
if (type !== "person" && type !== "account") {
|
|
585
|
+
return failure("type must be 'person' or 'account'");
|
|
586
|
+
}
|
|
587
|
+
let parentId = null;
|
|
588
|
+
if (type === "person" && body.parentId) {
|
|
589
|
+
const parent = state.identities.get(body.parentId);
|
|
590
|
+
if (!parent) return failure("parent identity not found");
|
|
591
|
+
if (parent.type !== "account") {
|
|
592
|
+
return failure("a person's parent must be an account");
|
|
593
|
+
}
|
|
594
|
+
parentId = parent.id;
|
|
595
|
+
}
|
|
596
|
+
const identity = {
|
|
597
|
+
id: crypto3.randomUUID(),
|
|
598
|
+
type,
|
|
599
|
+
parentId,
|
|
600
|
+
displayName: body.displayName ?? null,
|
|
601
|
+
profile: body.profile ?? {},
|
|
602
|
+
status: "active",
|
|
603
|
+
mergedIntoId: null,
|
|
604
|
+
externalRef: body.externalRef ?? null,
|
|
605
|
+
createdAt: now(),
|
|
606
|
+
updatedAt: now()
|
|
607
|
+
};
|
|
608
|
+
state.identities.set(identity.id, identity);
|
|
609
|
+
return success({ identity });
|
|
610
|
+
}
|
|
611
|
+
function handleGet(identityId) {
|
|
612
|
+
const identity = state.identities.get(identityId);
|
|
613
|
+
if (!identity) return failure("identity not found", 404);
|
|
614
|
+
return success({ identity, links: linksFor(identityId) });
|
|
615
|
+
}
|
|
616
|
+
async function handleUpdate(identityId, request) {
|
|
617
|
+
const identity = state.identities.get(identityId);
|
|
618
|
+
if (!identity) return failure("identity not found", 404);
|
|
619
|
+
const body = await readBody(request);
|
|
620
|
+
if (body.displayName !== void 0) {
|
|
621
|
+
identity.displayName = body.displayName;
|
|
622
|
+
}
|
|
623
|
+
if (body.profile !== void 0) {
|
|
624
|
+
identity.profile = body.profile;
|
|
625
|
+
}
|
|
626
|
+
if (body.externalRef !== void 0) {
|
|
627
|
+
identity.externalRef = body.externalRef;
|
|
628
|
+
}
|
|
629
|
+
identity.updatedAt = now();
|
|
630
|
+
return success({ identity });
|
|
631
|
+
}
|
|
632
|
+
async function handleAttachLink(identityId, request) {
|
|
633
|
+
const identity = state.identities.get(identityId);
|
|
634
|
+
if (!identity) return failure("identity not found", 404);
|
|
635
|
+
if (identity.type !== "person") {
|
|
636
|
+
return failure("links attach only to a person identity");
|
|
637
|
+
}
|
|
638
|
+
if (identity.status !== "active") {
|
|
639
|
+
return failure("links attach only to active persons");
|
|
640
|
+
}
|
|
641
|
+
const body = await readBody(request);
|
|
642
|
+
const kind = body.kind;
|
|
643
|
+
const externalId = body.externalId;
|
|
644
|
+
if (typeof kind !== "string" || !LINK_KINDS.has(kind)) {
|
|
645
|
+
return failure(`kind must be one of: ${[...LINK_KINDS].join(", ")}`);
|
|
646
|
+
}
|
|
647
|
+
if (typeof externalId !== "string" || !externalId) {
|
|
648
|
+
return failure("externalId is required");
|
|
649
|
+
}
|
|
650
|
+
const existing = state.identityLinks.find((l) => l.kind === kind && l.externalId === externalId);
|
|
651
|
+
if (existing) {
|
|
652
|
+
if (existing.identityId === identityId) return success({ link: existing });
|
|
653
|
+
return failure("identifier already linked to another identity", 409);
|
|
654
|
+
}
|
|
655
|
+
const link = {
|
|
656
|
+
id: crypto3.randomUUID(),
|
|
657
|
+
identityId,
|
|
658
|
+
kind,
|
|
659
|
+
externalId,
|
|
660
|
+
verified: body.verified === true,
|
|
661
|
+
createdAt: now()
|
|
662
|
+
};
|
|
663
|
+
state.identityLinks.push(link);
|
|
664
|
+
return success({ link });
|
|
665
|
+
}
|
|
666
|
+
async function handleIdentitiesRequest(request, subPath) {
|
|
667
|
+
const method = request.method;
|
|
668
|
+
if (subPath === "" || subPath === "/") {
|
|
669
|
+
if (method === "GET") return handleList(request);
|
|
670
|
+
if (method === "POST") return handleCreate(request);
|
|
671
|
+
}
|
|
672
|
+
const linksMatch = subPath.match(/^\/([^/]+)\/links$/);
|
|
673
|
+
if (linksMatch && method === "POST") {
|
|
674
|
+
return handleAttachLink(linksMatch[1], request);
|
|
675
|
+
}
|
|
676
|
+
const singleMatch = subPath.match(/^\/([^/]+)$/);
|
|
677
|
+
if (singleMatch) {
|
|
678
|
+
if (method === "GET") return handleGet(singleMatch[1]);
|
|
679
|
+
if (method === "PATCH") return handleUpdate(singleMatch[1], request);
|
|
680
|
+
}
|
|
681
|
+
return failure(`No identities simulator for ${method} .../identities${subPath}`, 404);
|
|
682
|
+
}
|
|
683
|
+
|
|
548
684
|
// src/simulator/router.ts
|
|
549
685
|
var fetchHolder = globalSingleton("fetch-holder", () => ({
|
|
550
686
|
originalFetch: null
|
|
@@ -562,7 +698,8 @@ async function handleSimulatedRequest(request, url) {
|
|
|
562
698
|
}
|
|
563
699
|
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
564
700
|
const isEmail = url.pathname === "/api/email/send";
|
|
565
|
-
|
|
701
|
+
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
702
|
+
if (dataStoreMatch || isEmail || identitiesMatch) {
|
|
566
703
|
const authHeader = request.headers.get("X-Stardeck-Auth");
|
|
567
704
|
if (!authHeader) {
|
|
568
705
|
return failure("Missing authentication header", 401);
|
|
@@ -575,28 +712,31 @@ async function handleSimulatedRequest(request, url) {
|
|
|
575
712
|
if (isEmail && request.method === "POST") {
|
|
576
713
|
return handleEmailSend(request);
|
|
577
714
|
}
|
|
715
|
+
if (identitiesMatch) {
|
|
716
|
+
return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
|
|
717
|
+
}
|
|
578
718
|
if (dataStoreMatch) {
|
|
579
719
|
const subPath = dataStoreMatch[1] ?? "";
|
|
580
720
|
const db = requireDb();
|
|
581
|
-
const
|
|
721
|
+
const readBody2 = async () => await request.json();
|
|
582
722
|
if (subPath === "/query" && request.method === "POST") {
|
|
583
|
-
return handleQuery(db, await
|
|
723
|
+
return handleQuery(db, await readBody2());
|
|
584
724
|
}
|
|
585
725
|
if (subPath === "/mutate" && request.method === "POST") {
|
|
586
|
-
return handleMutate(db, await
|
|
726
|
+
return handleMutate(db, await readBody2());
|
|
587
727
|
}
|
|
588
728
|
if (subPath === "/schema" && request.method === "GET") {
|
|
589
729
|
return handleGetSchema(db);
|
|
590
730
|
}
|
|
591
731
|
if (subPath === "/schema/tables" && request.method === "POST") {
|
|
592
|
-
return handleCreateTable(db, await
|
|
732
|
+
return handleCreateTable(db, await readBody2());
|
|
593
733
|
}
|
|
594
734
|
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
595
|
-
return handleAddColumn(db, await
|
|
735
|
+
return handleAddColumn(db, await readBody2());
|
|
596
736
|
}
|
|
597
737
|
}
|
|
598
738
|
return failure(
|
|
599
|
-
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
|
|
739
|
+
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, auth verify/refresh, Neon /sql.`,
|
|
600
740
|
404
|
|
601
741
|
);
|
|
602
742
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"@neondatabase/serverless": "^1.0.0",
|
|
79
79
|
"@stardeck-customer-apps/data-store-sdk": "*",
|
|
80
80
|
"@stardeck-customer-apps/email-sdk": "*",
|
|
81
|
+
"@stardeck-customer-apps/integrations-sdk": "*",
|
|
81
82
|
"@stardeck-customer-apps/tsconfig": "*",
|
|
82
83
|
"@types/node": "^24.10.1",
|
|
83
84
|
"kysely": "^0.27.0",
|