Skip to content

Commit b881b7a

Browse files
committed
feat(experimental): add http.readonly and http.returned field attrs
Wire edges can reject client-set fields, fn output validation strips response-excluded fields, and core parseFields/omitFields/rejectFields back those gates without hard-coding HTTP keys.
1 parent 61da7ff commit b881b7a

9 files changed

Lines changed: 385 additions & 116 deletions

File tree

packages/experimental/src/attrs.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { describe, expect, it } from "vitest";
22
import { ValidationError } from "./error";
33
import { v } from "./index";
4-
import { attrsOf, validate, withAttrs } from "./schema";
4+
import {
5+
asType,
6+
attrsOf,
7+
omitFields,
8+
parseFields,
9+
rejectFields,
10+
validate,
11+
withAttrs,
12+
} from "./schema";
513

614
describe("schema $attrs", () => {
715
it("withAttrs merges within a namespace and isolates namespaces", () => {
@@ -61,3 +69,69 @@ describe("schema $attrs", () => {
6169
).toBeDefined();
6270
});
6371
});
72+
73+
describe("omitFields / rejectFields / parseFields", () => {
74+
const dropMarked = (schema: unknown) =>
75+
attrsOf(schema, "http")?.readonly === true;
76+
const shape = v.object({
77+
id: v.string(),
78+
role: withAttrs(v.string(), "http", { readonly: true }),
79+
meta: v.object({
80+
note: v.string(),
81+
secret: withAttrs(v.string(), "http", { readonly: true }),
82+
}),
83+
});
84+
85+
it("omitFields projects nested objects", () => {
86+
const projected = asType(omitFields(shape, dropMarked));
87+
expect(Object.keys(projected.shape as object).sort()).toEqual([
88+
"id",
89+
"meta",
90+
]);
91+
const meta = asType((projected.shape as Record<string, unknown>).meta);
92+
expect(Object.keys(meta.shape as object)).toEqual(["note"]);
93+
});
94+
95+
it("rejectFields throws on smuggled keys", () => {
96+
expect(() =>
97+
rejectFields(shape, { id: "1", role: "admin" }, dropMarked),
98+
).toThrow(ValidationError);
99+
expect(() =>
100+
rejectFields(
101+
shape,
102+
{ id: "1", meta: { note: "n", secret: "s" } },
103+
dropMarked,
104+
),
105+
).toThrow(/field is not allowed/);
106+
});
107+
108+
it("parseFields rejects then validates the projection", () => {
109+
expect(
110+
parseFields(
111+
shape,
112+
{ id: "1", meta: { note: "hi" } },
113+
{ reject: dropMarked, omit: dropMarked },
114+
),
115+
).toEqual({ id: "1", meta: { note: "hi" } });
116+
expect(() =>
117+
parseFields(
118+
shape,
119+
{ id: "1", role: "admin" },
120+
{ reject: dropMarked, omit: dropMarked },
121+
),
122+
).toThrow(ValidationError);
123+
});
124+
125+
it("omitFields projects through a var schema", () => {
126+
const user = v.var("parse_user", {
127+
schema: shape,
128+
});
129+
const projected = omitFields(user, dropMarked);
130+
expect(projected.$var).toBe(true);
131+
expect(
132+
Object.keys(
133+
asType((projected as { schema: unknown }).schema).shape as object,
134+
).sort(),
135+
).toEqual(["id", "meta"]);
136+
});
137+
});

packages/experimental/src/capability.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -565,8 +565,9 @@ export const serve = async (
565565
const held = await spend(token);
566566
c.capability = { ...held, entry: c.input.call };
567567
}
568-
// Wire edge: reject http.serverOnly fields before the target
569-
// validates (object validate would otherwise strip them quietly).
568+
// Wire edge: reject http.readonly / serverOnly fields before the
569+
// target validates (object validate would otherwise strip them
570+
// quietly).
570571
const inputSchema = (target as FnDefination<any, any>).$schema?.input;
571572
if (inputSchema !== undefined) {
572573
rejectServerOnly(inputSchema, c.input.input, c.input.call);

packages/experimental/src/fn.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ describe("v.fn call forms", () => {
6565
expect(make({ ok: true })()).toEqual({ ok: true });
6666
expect(() => make({ ok: "nope" })()).toThrow(/fnt\.split\.output/);
6767
});
68+
69+
it("http.returned fields are stripped from output validation", async () => {
70+
const { returned } = await import("./plugins/http/attrs");
71+
const user = v.var("fnt_returned_user", {
72+
schema: v.object({
73+
id: v.string(),
74+
password: returned(v.string()),
75+
}),
76+
});
77+
const f = v.fn("fnt.returned", { output: user }, () => ({
78+
id: "1",
79+
password: "secret",
80+
}));
81+
expect(f()).toEqual({ id: "1" });
82+
});
6883
});
6984

7085
describe("omittable input", () => {

packages/experimental/src/fn.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ import {
3030
} from "./module";
3131
import {
3232
asType,
33+
attrsOf,
3334
type InferArgs,
3435
type InferInput,
3536
isVar,
3637
type OutputSchemaOf,
38+
omitFields,
3739
outputContract,
3840
type TypeDefination,
3941
validate,
@@ -813,7 +815,17 @@ const defineFn = (
813815

814816
// Only the VALIDATION half of the output contract is checked on exit -
815817
// a `{ def }`-only output is a documented promise, never a check.
816-
const outputValidation = outputContract(options.output).validation;
818+
// Fields marked `http.returned` are projected out so they never leave
819+
// the process through this fn's result (direct in-process data on the
820+
// handler's locals is unaffected).
821+
const rawOutputValidation = outputContract(options.output).validation;
822+
const outputValidation =
823+
rawOutputValidation === undefined
824+
? undefined
825+
: omitFields(
826+
rawOutputValidation,
827+
(field) => attrsOf(field, "http")?.returned === true,
828+
);
817829
const errorTypes = declaredErrors
818830
? Object.fromEntries(
819831
Object.entries(declaredErrors).map(([tag, schema]) => [
@@ -1196,8 +1208,10 @@ const defineFn = (
11961208
);
11971209

11981210
// Exit contracts run after the body, whether or not it was async.
1211+
// Output validation both checks AND projects (so `http.returned`
1212+
// fields / undeclared keys leave through the validated shape).
11991213
const finish = (result: unknown) => {
1200-
const afterOutput = () => {
1214+
const afterOutput = (out: unknown) => {
12011215
if (bodyRan) {
12021216
for (const name of options.provides ?? []) {
12031217
if (missing(name)) {
@@ -1208,9 +1222,9 @@ const defineFn = (
12081222
}
12091223
}
12101224
}
1211-
return result;
1225+
return out;
12121226
};
1213-
if (outputValidation === undefined) return afterOutput();
1227+
if (outputValidation === undefined) return afterOutput(result);
12141228
return thenMaybe(
12151229
validate(asType(outputValidation), result, `${key}.output`),
12161230
afterOutput,

packages/experimental/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,15 @@ export {
145145
export {
146146
type AttrBag,
147147
attrsOf,
148+
type FieldPred,
148149
type InferArgs,
149150
type InferInput,
150151
type InferOutput,
151152
type InferType,
153+
omitFields,
154+
type ParseFieldsOptions,
155+
parseFields,
156+
rejectFields,
152157
type TypeDefination,
153158
withAttrs,
154159
} from "./schema";

packages/experimental/src/plugins/http/attrs.test.ts

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,91 +5,140 @@ import { asType, attrsOf, validate } from "../../schema";
55
import {
66
clientSchema,
77
fromJsonBody,
8-
rejectServerOnly,
8+
readonly,
9+
rejectReadonly,
10+
responseSchema,
11+
returned,
912
serverOnly,
1013
wireInput,
1114
} from "./attrs";
1215

1316
describe("http field attrs", () => {
1417
const shape = {
1518
id: v.string(),
16-
role: serverOnly(v.string()),
19+
role: readonly(v.string()),
20+
password: returned(v.string()),
1721
meta: v.object({
1822
note: v.string(),
19-
secret: serverOnly(v.string()),
23+
secret: readonly(v.string()),
24+
hash: returned(v.string()),
2025
}),
2126
};
2227

23-
it("serverOnly writes $attrs.http", () => {
28+
it("readonly and returned write $attrs.http", () => {
29+
expect(attrsOf(readonly(v.string()), "http")).toEqual({
30+
readonly: true,
31+
});
32+
expect(attrsOf(returned(v.string()), "http")).toEqual({
33+
returned: true,
34+
});
2435
expect(attrsOf(serverOnly(v.string()), "http")).toEqual({
2536
serverOnly: true,
2637
});
2738
});
2839

29-
it("clientSchema drops server-only fields nested", () => {
40+
it("clientSchema drops readonly fields nested", () => {
3041
const projected = asType(clientSchema(v.object(shape)));
3142
expect(Object.keys(projected.shape as object).sort()).toEqual([
3243
"id",
3344
"meta",
45+
"password",
46+
]);
47+
const meta = asType((projected.shape as Record<string, unknown>).meta);
48+
expect(Object.keys(meta.shape as object).sort()).toEqual(["hash", "note"]);
49+
});
50+
51+
it("responseSchema drops returned fields nested", () => {
52+
const projected = asType(responseSchema(v.object(shape)));
53+
expect(Object.keys(projected.shape as object).sort()).toEqual([
54+
"id",
55+
"meta",
56+
"role",
3457
]);
3558
const meta = asType((projected.shape as Record<string, unknown>).meta);
36-
expect(Object.keys(meta.shape as object)).toEqual(["note"]);
59+
expect(Object.keys(meta.shape as object).sort()).toEqual([
60+
"note",
61+
"secret",
62+
]);
3763
});
3864

39-
it("rejectServerOnly fails when a server-only key is present", () => {
65+
it("rejectReadonly fails when a readonly key is present", () => {
4066
expect(() =>
41-
rejectServerOnly(v.object(shape), {
67+
rejectReadonly(v.object(shape), {
4268
id: "1",
4369
role: "admin",
4470
}),
4571
).toThrow(ValidationError);
4672
expect(() =>
47-
rejectServerOnly(v.object(shape), {
73+
rejectReadonly(v.object(shape), {
4874
id: "1",
4975
meta: { note: "hi", secret: "x" },
5076
}),
51-
).toThrow(/server-only/);
77+
).toThrow(/readonly field/);
5278
});
5379

54-
it("wireInput accepts client fields and rejects smuggled server-only", () => {
80+
it("wireInput accepts client fields and rejects smuggled readonly", () => {
5581
expect(
5682
wireInput(v.object(shape), {
5783
id: "1",
58-
meta: { note: "hi" },
84+
password: "secret",
85+
meta: { note: "hi", hash: "h" },
5986
}),
60-
).toEqual({ id: "1", meta: { note: "hi" } });
87+
).toEqual({
88+
id: "1",
89+
password: "secret",
90+
meta: { note: "hi", hash: "h" },
91+
});
6192
expect(() =>
6293
wireInput(v.object(shape), { id: "1", role: "admin" }),
6394
).toThrow(ValidationError);
6495
});
6596

66-
it("in-process validate still accepts server-only fields", () => {
97+
it("serverOnly still gates the wire like readonly", () => {
98+
const schema = v.object({
99+
id: v.string(),
100+
role: serverOnly(v.string()),
101+
});
102+
expect(() => wireInput(schema, { id: "1", role: "admin" })).toThrow(
103+
/readonly field/,
104+
);
105+
expect(wireInput(schema, { id: "1" })).toEqual({ id: "1" });
106+
});
107+
108+
it("in-process validate still accepts readonly fields", () => {
67109
expect(
68110
validate(
69111
asType(v.object(shape)),
70112
{
71113
id: "1",
72114
role: "admin",
73-
meta: { note: "n", secret: "s" },
115+
password: "p",
116+
meta: { note: "n", secret: "s", hash: "h" },
74117
},
75118
"user",
76119
),
77120
).toEqual({
78121
id: "1",
79122
role: "admin",
80-
meta: { note: "n", secret: "s" },
123+
password: "p",
124+
meta: { note: "n", secret: "s", hash: "h" },
81125
});
82126
});
83127

84128
it("fromJsonBody runs wireInput on the request body", async () => {
85129
const request = new Request("https://example.com", {
86130
method: "POST",
87131
headers: { "content-type": "application/json" },
88-
body: JSON.stringify({ id: "1", meta: { note: "ok" } }),
132+
body: JSON.stringify({
133+
id: "1",
134+
password: "p",
135+
meta: { note: "ok", hash: "h" },
136+
}),
89137
});
90138
await expect(fromJsonBody(request, v.object(shape))).resolves.toEqual({
91139
id: "1",
92-
meta: { note: "ok" },
140+
password: "p",
141+
meta: { note: "ok", hash: "h" },
93142
});
94143

95144
const smuggle = new Request("https://example.com", {
@@ -111,12 +160,12 @@ describe("http field attrs", () => {
111160
);
112161
});
113162

114-
it("clientSchema and rejectServerOnly recurse into union arms", () => {
163+
it("clientSchema and rejectReadonly recurse into union arms", () => {
115164
const schema = v.union([
116165
v.object({
117166
kind: v.string({ enum: ["user"] }),
118167
id: v.string(),
119-
role: serverOnly(v.string()),
168+
role: readonly(v.string()),
120169
}),
121170
v.object({
122171
kind: v.string({ enum: ["anon"] }),
@@ -136,8 +185,8 @@ describe("http field attrs", () => {
136185
]);
137186

138187
expect(() =>
139-
rejectServerOnly(schema, { kind: "user", id: "1", role: "admin" }),
140-
).toThrow(/server-only/);
188+
rejectReadonly(schema, { kind: "user", id: "1", role: "admin" }),
189+
).toThrow(/readonly field/);
141190
expect(wireInput(schema, { kind: "anon", token: "t" })).toEqual({
142191
kind: "anon",
143192
token: "t",

0 commit comments

Comments
 (0)