@pylonsync/sdk 0.3.259 → 0.3.261

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +89 -5
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.259",
6
+ "version": "0.3.261",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -164,6 +164,36 @@ interface FieldBuilder {
164
164
  * sitting in SQLite.
165
165
  */
166
166
  encrypted(): FieldBuilder;
167
+ /**
168
+ * Mark the field as the row's **owner** — the framework stamps it
169
+ * with `auth.userId` on every insert and refuses any client attempt
170
+ * to set it to a different user. This is what makes *optimistic,
171
+ * local-first writes the default* for owned data: the client can
172
+ * `db.insert("Offer", { buyerId, … })` directly (paints the row in
173
+ * the local store instantly, no server round-trip) and still be
174
+ * secure — the server overwrites/validates the owner from the
175
+ * session, so a forged `buyerId` is rejected before it ever lands.
176
+ *
177
+ * Concretely `.owner()`:
178
+ * - on **insert**, fills the field from `auth.userId` when omitted,
179
+ * and rejects a non-admin caller who supplies a *different*
180
+ * non-empty value (`OWNER_MISMATCH`, 403) — closing the IDOR
181
+ * shape where a policy gates on `data.ownerId == auth.userId`
182
+ * but the attacker just sends someone else's id;
183
+ * - on **update**, behaves like [`readonly`] — the owner can't be
184
+ * reassigned through the HTTP entity routes.
185
+ *
186
+ * Reach for this instead of writing a server function whenever the
187
+ * only server-authoritative part of a create is "who made it":
188
+ * `authorId`, `buyerId`, `sellerId`, `createdBy`, `userId`. Guests
189
+ * count — their stable guest id is stamped. Admin contexts may set
190
+ * an explicit value (migrations, tooling).
191
+ *
192
+ * Example: `sellerId: field.string().owner()` lets a listing be
193
+ * created with a plain optimistic `db.insert` while the seller id
194
+ * stays unspoofable.
195
+ */
196
+ owner(): FieldBuilder;
167
197
  }
168
198
 
169
199
  function createFieldBuilder(type: FieldType): FieldBuilder {
@@ -191,6 +221,15 @@ function buildField(def: FieldDefinition): FieldBuilder {
191
221
  encrypted() {
192
222
  return buildField({ ...def, encrypted: true });
193
223
  },
224
+ owner() {
225
+ // Dynamic default filled by the auth-aware pipeline; also lock
226
+ // the field on update so ownership can't be reassigned via PATCH.
227
+ return buildField({
228
+ ...def,
229
+ readonly: true,
230
+ default: { kind: "owner" },
231
+ } as FieldDefinition);
232
+ },
194
233
  };
195
234
  }
196
235
 
@@ -440,6 +479,24 @@ export function definePlugin(def: PluginDefinition): PluginDefinition {
440
479
  // Manifest generation
441
480
  // ---------------------------------------------------------------------------
442
481
 
482
+ /**
483
+ * Serialized form of `field.X().owner()`. Carried in a field's
484
+ * `default` slot as a *dynamic* default — the value isn't known until
485
+ * a request arrives, so the framework's auth-aware mutation pipeline
486
+ * (OwnerStampPlugin, Rust side) fills it from `auth.userId` on insert
487
+ * and rejects any client attempt to set it to a different user. The
488
+ * storage-layer default-filler (`apply_field_defaults`) deliberately
489
+ * skips this shape — it has no auth context, so it can't (and must
490
+ * not) stamp an owner.
491
+ *
492
+ * `$auth: "userId"` is the only kind today; the object shape leaves
493
+ * room for future auth-derived defaults (e.g. `{$auth:"tenantId"}`)
494
+ * without another manifest migration.
495
+ */
496
+ export interface OwnerDefaultSentinel {
497
+ $auth: "userId";
498
+ }
499
+
443
500
  export interface ManifestField {
444
501
  name: string;
445
502
  type: FieldType;
@@ -457,9 +514,14 @@ export interface ManifestField {
457
514
  readonly?: boolean;
458
515
  /** Default value to fill on insert when the row omits this field.
459
516
  * - `"now"` → runtime stamps the current UTC time
517
+ * - `{$auth:…}` → a *dynamic* default the auth-aware mutation
518
+ * pipeline fills (see [`OwnerDefaultSentinel`]).
519
+ * Never stamped at the storage layer — the
520
+ * OwnerStampPlugin fills + enforces it.
460
521
  * - any literal → runtime stamps that exact value
461
- * Maps to `field.X().defaultNow()` / `.default(value)`. */
462
- default?: "now" | string | number | boolean | null;
522
+ * Maps to `field.X().defaultNow()` / `.default(value)` /
523
+ * `field.X().owner()`. */
524
+ default?: "now" | string | number | boolean | null | OwnerDefaultSentinel;
463
525
  /** Allowed values for `field.enum([...])` — recorded so codegen
464
526
  * can emit a literal-union type and runtime validation can
465
527
  * reject out-of-set inserts. Plain `field.string()` doesn't
@@ -595,14 +657,22 @@ export function entitiesToManifest(
595
657
  // using the procedural `field` exports get the same
596
658
  // ManifestField shape as fluent apps.
597
659
  const extra = fb._def as FieldDefinition & {
598
- default?: { kind: "value"; value: unknown } | { kind: "now" };
660
+ default?:
661
+ | { kind: "value"; value: unknown }
662
+ | { kind: "now" }
663
+ | { kind: "owner" };
599
664
  enumValues?: readonly string[];
600
665
  };
601
666
  if (extra.default) {
602
667
  f.default =
603
668
  extra.default.kind === "now"
604
669
  ? "now"
605
- : (extra.default.value as ManifestField["default"]);
670
+ : extra.default.kind === "owner"
671
+ ? // Dynamic, auth-derived default. Carried as a sentinel
672
+ // object the Rust mutation pipeline recognizes; the
673
+ // storage-layer default-filler skips it (no auth there).
674
+ ({ $auth: "userId" } satisfies OwnerDefaultSentinel)
675
+ : (extra.default.value as ManifestField["default"]);
606
676
  }
607
677
  if (extra.enumValues && extra.enumValues.length > 0) {
608
678
  f.enumValues = extra.enumValues;
@@ -1416,7 +1486,10 @@ export const audit: Behavior = {
1416
1486
  * below; this is just the shape stored on the field definition for
1417
1487
  * the runtime to read.
1418
1488
  */
1419
- type DefaultMarker = { kind: "value"; value: unknown } | { kind: "now" };
1489
+ type DefaultMarker =
1490
+ | { kind: "value"; value: unknown }
1491
+ | { kind: "now" }
1492
+ | { kind: "owner" };
1420
1493
 
1421
1494
  /**
1422
1495
  * Augment FieldBuilder with the new `default()` / `defaultNow()`
@@ -1448,6 +1521,7 @@ function buildFieldWithDefaults(
1448
1521
  ): FieldBuilder & {
1449
1522
  default(value: unknown): ReturnType<typeof buildFieldWithDefaults>;
1450
1523
  defaultNow(): ReturnType<typeof buildFieldWithDefaults>;
1524
+ owner(): ReturnType<typeof buildFieldWithDefaults>;
1451
1525
  } {
1452
1526
  return {
1453
1527
  _def: def,
@@ -1478,6 +1552,16 @@ function buildFieldWithDefaults(
1478
1552
  defaultNow() {
1479
1553
  return buildFieldWithDefaults({ ...def, default: { kind: "now" } });
1480
1554
  },
1555
+ owner() {
1556
+ // Server-stamped owner: a dynamic default the auth-aware
1557
+ // pipeline fills + enforces, plus `readonly` so the owner can't
1558
+ // be reassigned via a later PATCH. See FieldBuilder.owner().
1559
+ return buildFieldWithDefaults({
1560
+ ...def,
1561
+ readonly: true,
1562
+ default: { kind: "owner" },
1563
+ });
1564
+ },
1481
1565
  };
1482
1566
  }
1483
1567
  // Re-export `field` with the patched builder so callers picking up