@jxsuite/connector 0.1.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/src/types.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Connector contract types (specs/extensions.md §11–§12, plan Part 4a).
3
+ *
4
+ * These are the shapes the connector publishes for its dependents: the auth extension implements
5
+ * `JxAuthHooks` against `AuthorizeInput`/`AuthorizeDecision`, server hosts share one
6
+ * `JxServerContext` across ordered mounts, and every module in this package speaks the table /
7
+ * connection definitions declared in project.json's `data` and `connections` sections.
8
+ */
9
+
10
+ // ─── Field / table schema ─────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * A column field schema — the JSON-Schema subset published as the core field union
14
+ * (`JxFieldSchema`) plus the relationship-ref form (`{ $ref: "#/<section>/<name>" }`,
15
+ * specs/relationships.md §1). Plain JxFieldSchema + RelationshipRef cover columns v1; the connector
16
+ * ships no field-union extras.
17
+ */
18
+ export interface ColumnFieldSchema {
19
+ type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
20
+ /** Relationship reference: "#/<sectionKey>/<entryName>". */
21
+ $ref?: string;
22
+ items?: ColumnFieldSchema;
23
+ properties?: Record<string, ColumnFieldSchema>;
24
+ required?: string[];
25
+ enum?: unknown[];
26
+ format?: string;
27
+ default?: unknown;
28
+ description?: string;
29
+ }
30
+
31
+ /** The `schema` block of a table definition. */
32
+ export interface TableSchema {
33
+ type?: "object";
34
+ properties?: Record<string, ColumnFieldSchema>;
35
+ required?: string[];
36
+ }
37
+
38
+ /** One entry of the project.json `data` section. */
39
+ export interface TableDef {
40
+ connection: string;
41
+ schema: TableSchema;
42
+ id?: "uuid" | "integer";
43
+ timestamps?: boolean;
44
+ indexes?: (string | string[])[];
45
+ permissions?: TablePermissions;
46
+ ownerField?: string;
47
+ }
48
+
49
+ /** One entry of the project.json `connections` section (identifiers only — never secrets). */
50
+ export interface ConnectionDef {
51
+ provider: string;
52
+ binding?: string;
53
+ databaseId?: string;
54
+ accountId?: string;
55
+ urlEnv?: string;
56
+ hyperdriveId?: string;
57
+ file?: string;
58
+ /** Connection name, injected by hosts when passing the def to connector capabilities. */
59
+ $name?: string;
60
+ [key: string]: unknown;
61
+ }
62
+
63
+ // ─── Permissions ──────────────────────────────────────────────────────────────
64
+
65
+ export type PermissionAction = "read" | "insert" | "update" | "delete";
66
+
67
+ /** Access rule: `role:<r>` grants the named role (evaluated by the auth extension). */
68
+ export type PermissionRule = "public" | "none" | "authenticated" | "owner" | `role:${string}`;
69
+
70
+ export type TablePermissions = Partial<Record<PermissionAction, PermissionRule>>;
71
+
72
+ /** Default rules when a table declares none: reads are public, writes are closed. */
73
+ export const DEFAULT_PERMISSIONS: Record<PermissionAction, PermissionRule> = {
74
+ delete: "none",
75
+ insert: "none",
76
+ read: "public",
77
+ update: "none",
78
+ };
79
+
80
+ // ─── Auth contract (implemented by @jxsuite/auth in Part 4b) ─────────────────
81
+
82
+ /** The session shape auth hooks resolve from a request. */
83
+ export interface SessionInfo {
84
+ userId: string;
85
+ role?: string;
86
+ /** Full user record, when the auth provider exposes one. */
87
+ user?: Record<string, unknown>;
88
+ [key: string]: unknown;
89
+ }
90
+
91
+ export interface AuthorizeInput {
92
+ table: string;
93
+ action: PermissionAction;
94
+ rule: PermissionRule;
95
+ session: SessionInfo | null;
96
+ ownerField?: string;
97
+ }
98
+
99
+ export interface AuthorizeDecision {
100
+ allow: boolean;
101
+ /** HTTP status for a denial (401 unauthenticated, 403 forbidden). */
102
+ status?: number;
103
+ error?: string;
104
+ /** Columns forced onto insert/update payloads (e.g. the owner id). */
105
+ setColumns?: Record<string, unknown>;
106
+ /** Row filter scoping reads/writes to the owner: `WHERE <field> = <value>`. */
107
+ whereOwner?: { field: string; value: unknown } | null;
108
+ }
109
+
110
+ /** The hooks an auth mount publishes on the shared server context. */
111
+ export interface JxAuthHooks {
112
+ getSession: (request: Request, env: Record<string, unknown>) => Promise<SessionInfo | null>;
113
+ authorize: (input: AuthorizeInput, env: Record<string, unknown>) => Promise<AuthorizeDecision>;
114
+ }
115
+
116
+ /**
117
+ * One shared mutable context per worker isolate, passed to every mount in `server.order`
118
+ * (specs/extensions.md §11). The auth mount (order 10) sets `auth`; the data mount (order 20)
119
+ * consumes it and fails closed when it is absent.
120
+ */
121
+ export interface JxServerContext {
122
+ auth?: JxAuthHooks;
123
+ [key: string]: unknown;
124
+ }
125
+
126
+ // ─── Deploy / dialect capability results ─────────────────────────────────────
127
+
128
+ /** Result shape of the `deploySchema` connector capability. */
129
+ export interface DeploySchemaResult {
130
+ statements: string[];
131
+ applied: boolean;
132
+ warnings: string[];
133
+ }
134
+
135
+ /** Result shape of the `testConnection` connector capability. */
136
+ export interface TestConnectionResult {
137
+ ok: boolean;
138
+ error?: string;
139
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Validate — hand-rolled row validation against a table's column schema.
3
+ *
4
+ * Deliberately not ajv: ajv compiles validators with `new Function`, which Workers forbid. The
5
+ * grammar here is the small field-schema subset tables actually use (scalar types, arrays, objects,
6
+ * enums, relationship refs), plus payload-level rules: unknown and read-only fields are rejected,
7
+ * required fields are enforced on full (insert) validation, and form-shaped string values are
8
+ * coerced to their declared column types ("42" → 42, "true" → true) so plain FormData submissions
9
+ * validate cleanly.
10
+ */
11
+
12
+ import { parseRefPointer } from "./columns.ts";
13
+ import type { ColumnFieldSchema, TableDef } from "./types.ts";
14
+
15
+ export interface ValidationIssue {
16
+ field: string;
17
+ message: string;
18
+ }
19
+
20
+ export interface ValidationResult {
21
+ valid: boolean;
22
+ errors: ValidationIssue[];
23
+ /** The coerced payload (schema-typed values), keyed by schema field name. */
24
+ value: Record<string, unknown>;
25
+ }
26
+
27
+ /** Columns the data mount owns; clients may never write them. */
28
+ const READ_ONLY_FIELDS = new Set(["id", "created_at", "updated_at"]);
29
+
30
+ /**
31
+ * Validate (and coerce) a row payload against a table definition.
32
+ *
33
+ * @param {TableDef} table - The table definition from the `data` section
34
+ * @param {unknown} payload - Parsed request body
35
+ * @param {{ partial?: boolean }} [options] - `partial` skips required-field checks (PATCH)
36
+ * @returns {ValidationResult}
37
+ */
38
+ export function validateRow(
39
+ table: TableDef,
40
+ payload: unknown,
41
+ options: { partial?: boolean } = {},
42
+ ): ValidationResult {
43
+ const { partial = false } = options;
44
+ const errors: ValidationIssue[] = [];
45
+ const value: Record<string, unknown> = {};
46
+
47
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
48
+ return { errors: [{ field: "", message: "Body must be a JSON object" }], valid: false, value };
49
+ }
50
+ const body = payload as Record<string, unknown>;
51
+ const fields = table.schema.properties ?? {};
52
+
53
+ for (const [key, raw] of Object.entries(body)) {
54
+ if (READ_ONLY_FIELDS.has(key)) {
55
+ errors.push({ field: key, message: "Read-only field" });
56
+ continue;
57
+ }
58
+ const schema = fields[key];
59
+ if (!schema) {
60
+ errors.push({ field: key, message: "Unknown field" });
61
+ continue;
62
+ }
63
+ const coerced = coerceField(key, schema, raw, errors);
64
+ if (coerced !== INVALID) {
65
+ value[key] = coerced;
66
+ }
67
+ }
68
+
69
+ if (!partial) {
70
+ for (const required of table.schema.required ?? []) {
71
+ if (body[required] === undefined) {
72
+ errors.push({ field: required, message: "Required field is missing" });
73
+ }
74
+ }
75
+ }
76
+
77
+ return { errors, valid: errors.length === 0, value };
78
+ }
79
+
80
+ /** Sentinel distinguishing "coercion failed" from a legitimate undefined/null value. */
81
+ const INVALID = Symbol("invalid");
82
+
83
+ /**
84
+ * Coerce one payload value against its field schema, appending errors on mismatch.
85
+ *
86
+ * @param {string} field
87
+ * @param {ColumnFieldSchema} schema
88
+ * @param {unknown} raw
89
+ * @param {ValidationIssue[]} errors
90
+ * @returns {unknown} The coerced value, or the INVALID sentinel
91
+ */
92
+ function coerceField(
93
+ field: string,
94
+ schema: ColumnFieldSchema,
95
+ raw: unknown,
96
+ errors: ValidationIssue[],
97
+ ): unknown {
98
+ if (raw === null || raw === undefined) {
99
+ return null;
100
+ }
101
+
102
+ const toOne = parseRefPointer(schema.$ref);
103
+ if (toOne) {
104
+ if (typeof raw !== "string" || raw === "") {
105
+ errors.push({ field, message: "Reference value must be an entry id string" });
106
+ return INVALID;
107
+ }
108
+ return raw;
109
+ }
110
+
111
+ const itemsRef = schema.type === "array" ? parseRefPointer(schema.items?.$ref) : null;
112
+ if (itemsRef) {
113
+ const list = parseMaybeJson(raw);
114
+ if (!Array.isArray(list) || list.some((item) => typeof item !== "string" || item === "")) {
115
+ errors.push({ field, message: "Reference list must be an array of entry id strings" });
116
+ return INVALID;
117
+ }
118
+ return list;
119
+ }
120
+
121
+ const coerced = coerceScalar(schema, raw);
122
+ if (coerced === INVALID) {
123
+ errors.push({ field, message: `Expected ${schema.type ?? "string"}` });
124
+ return INVALID;
125
+ }
126
+ if (schema.enum && !schema.enum.some((allowed) => allowed === coerced)) {
127
+ errors.push({ field, message: "Value is not one of the allowed choices" });
128
+ return INVALID;
129
+ }
130
+ return coerced;
131
+ }
132
+
133
+ /** Coerce a scalar payload value to its schema type, or INVALID. */
134
+ function coerceScalar(schema: ColumnFieldSchema, raw: unknown): unknown {
135
+ switch (schema.type) {
136
+ case "number":
137
+ case "integer": {
138
+ let num: number;
139
+ if (typeof raw === "number") {
140
+ num = raw;
141
+ } else if (typeof raw === "string" && raw.trim() !== "" && !Number.isNaN(Number(raw))) {
142
+ num = Number(raw);
143
+ } else {
144
+ return INVALID;
145
+ }
146
+ if (schema.type === "integer" && !Number.isInteger(num)) {
147
+ return INVALID;
148
+ }
149
+ return num;
150
+ }
151
+ case "boolean": {
152
+ if (typeof raw === "boolean") {
153
+ return raw;
154
+ }
155
+ if (raw === "true" || raw === "on" || raw === "1" || raw === 1) {
156
+ return true;
157
+ }
158
+ if (raw === "false" || raw === "off" || raw === "0" || raw === 0 || raw === "") {
159
+ return false;
160
+ }
161
+ return INVALID;
162
+ }
163
+ case "array": {
164
+ const parsed = parseMaybeJson(raw);
165
+ return Array.isArray(parsed) ? parsed : INVALID;
166
+ }
167
+ case "object": {
168
+ const parsed = parseMaybeJson(raw);
169
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
170
+ ? parsed
171
+ : INVALID;
172
+ }
173
+ default: {
174
+ return typeof raw === "string" ? raw : INVALID;
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Parse JSON-looking strings (FormData carries arrays/objects as text); pass everything else. */
180
+ function parseMaybeJson(raw: unknown): unknown {
181
+ if (typeof raw !== "string") {
182
+ return raw;
183
+ }
184
+ const text = raw.trim();
185
+ if (!text.startsWith("[") && !text.startsWith("{")) {
186
+ return raw;
187
+ }
188
+ try {
189
+ return JSON.parse(text) as unknown;
190
+ } catch {
191
+ return raw;
192
+ }
193
+ }