@supatype/cli 0.1.5 → 0.1.7
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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +143 -93
- package/.turbo/turbo-typecheck.log +1 -1
- package/deno.d.ts +52 -0
- package/dist/augmentation-generator.d.ts +22 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +43 -7
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/binary-cache.d.ts +14 -0
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +33 -13
- package/dist/binary-cache.js.map +1 -1
- package/dist/cache-clean.d.ts +15 -0
- package/dist/cache-clean.d.ts.map +1 -0
- package/dist/cache-clean.js +76 -0
- package/dist/cache-clean.js.map +1 -0
- package/dist/cache-pins.d.ts +51 -0
- package/dist/cache-pins.d.ts.map +1 -0
- package/dist/cache-pins.js +184 -0
- package/dist/cache-pins.js.map +1 -0
- package/dist/cli-install-method.d.ts +19 -0
- package/dist/cli-install-method.d.ts.map +1 -0
- package/dist/cli-install-method.js +60 -0
- package/dist/cli-install-method.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-credentials.d.ts +34 -0
- package/dist/cloud-credentials.d.ts.map +1 -0
- package/dist/cloud-credentials.js +139 -0
- package/dist/cloud-credentials.js.map +1 -0
- package/dist/commands/admin.d.ts +11 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +131 -22
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +67 -71
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +32 -8
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/db.d.ts +1 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +147 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +50 -10
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +28 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +60 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/functions.d.ts +7 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +40 -6
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/hooks.d.ts +12 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +118 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +41 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/internal.d.ts +6 -0
- package/dist/commands/internal.d.ts.map +1 -0
- package/dist/commands/internal.js +22 -0
- package/dist/commands/internal.js.map +1 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +2 -0
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/link-helpers.d.ts.map +1 -1
- package/dist/commands/link-helpers.js +8 -2
- package/dist/commands/link-helpers.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +110 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-update.d.ts +7 -0
- package/dist/commands/self-update.d.ts.map +1 -1
- package/dist/commands/self-update.js +18 -11
- package/dist/commands/self-update.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/dist/db-preflight.d.ts +78 -0
- package/dist/db-preflight.d.ts.map +1 -0
- package/dist/db-preflight.js +452 -0
- package/dist/db-preflight.js.map +1 -0
- package/dist/dev-compose.d.ts +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +129 -72
- package/dist/dev-compose.js.map +1 -1
- package/dist/env-file.d.ts +40 -2
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +132 -11
- package/dist/env-file.js.map +1 -1
- package/dist/field-masking-tier.d.ts +50 -0
- package/dist/field-masking-tier.d.ts.map +1 -0
- package/dist/field-masking-tier.js +91 -0
- package/dist/field-masking-tier.js.map +1 -0
- package/dist/functions-deno-types.d.ts +24 -0
- package/dist/functions-deno-types.d.ts.map +1 -0
- package/dist/functions-deno-types.js +155 -0
- package/dist/functions-deno-types.js.map +1 -0
- package/dist/hook-upload.d.ts +30 -0
- package/dist/hook-upload.d.ts.map +1 -0
- package/dist/hook-upload.js +86 -0
- package/dist/hook-upload.js.map +1 -0
- package/dist/hooks-generator.d.ts +8 -0
- package/dist/hooks-generator.d.ts.map +1 -0
- package/dist/hooks-generator.js +332 -0
- package/dist/hooks-generator.js.map +1 -0
- package/dist/link.d.ts +4 -0
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +2 -0
- package/dist/link.js.map +1 -1
- package/dist/local-secrets.d.ts +71 -0
- package/dist/local-secrets.d.ts.map +1 -0
- package/dist/local-secrets.js +100 -0
- package/dist/local-secrets.js.map +1 -0
- package/dist/model-hooks.d.ts +73 -0
- package/dist/model-hooks.d.ts.map +1 -0
- package/dist/model-hooks.js +211 -0
- package/dist/model-hooks.js.map +1 -0
- package/dist/postgres-ctl.d.ts +11 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +25 -2
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +121 -1
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +150 -1
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +4 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js +69 -73
- package/dist/resolve-target.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +2 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +4 -2
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/schema-push-lock.d.ts.map +1 -1
- package/dist/schema-push-lock.js +43 -16
- package/dist/schema-push-lock.js.map +1 -1
- package/dist/self-host-compose.d.ts +24 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +301 -69
- package/dist/self-host-compose.js.map +1 -1
- package/dist/service-role-check.d.ts +21 -0
- package/dist/service-role-check.d.ts.map +1 -0
- package/dist/service-role-check.js +85 -0
- package/dist/service-role-check.js.map +1 -0
- package/dist/target-client.d.ts +17 -0
- package/dist/target-client.d.ts.map +1 -1
- package/dist/target-client.js +69 -17
- package/dist/target-client.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +736 -32
- package/dist/type-extractor.js.map +1 -1
- package/dist/ui/runtime/command-chrome.js +1 -1
- package/dist/ui/runtime/command-chrome.js.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.js +3 -1
- package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
- package/package.json +4 -1
- package/scripts/generate-homebrew-formula.mjs +121 -0
- package/src/augmentation-generator.ts +46 -9
- package/src/binary-cache.ts +47 -15
- package/src/cache-clean.ts +81 -0
- package/src/cache-pins.ts +243 -0
- package/src/cli-install-method.ts +67 -0
- package/src/cli.ts +6 -0
- package/src/cloud-credentials.ts +183 -0
- package/src/commands/admin.ts +153 -28
- package/src/commands/cache.ts +90 -75
- package/src/commands/cloud.ts +32 -8
- package/src/commands/db.ts +162 -1
- package/src/commands/dev.ts +57 -8
- package/src/commands/doctor.ts +69 -3
- package/src/commands/functions.ts +42 -5
- package/src/commands/generate.ts +5 -1
- package/src/commands/hooks.ts +138 -0
- package/src/commands/init.ts +42 -8
- package/src/commands/internal.ts +23 -0
- package/src/commands/keys.ts +2 -0
- package/src/commands/link-helpers.ts +9 -2
- package/src/commands/login.ts +127 -0
- package/src/commands/push.ts +124 -7
- package/src/commands/self-update.ts +85 -13
- package/src/config.ts +22 -3
- package/src/db-preflight.ts +575 -0
- package/src/dev-compose.ts +144 -73
- package/src/env-file.ts +157 -11
- package/src/field-masking-tier.ts +107 -0
- package/src/functions-deno-types.ts +188 -0
- package/src/hook-upload.ts +107 -0
- package/src/hooks-generator.ts +352 -0
- package/src/link.ts +7 -0
- package/src/local-secrets.ts +104 -0
- package/src/model-hooks.ts +260 -0
- package/src/postgres-ctl.ts +29 -2
- package/src/project-config.ts +236 -2
- package/src/resolve-target.ts +113 -68
- package/src/schema-ast-v2.ts +5 -2
- package/src/schema-push-lock.ts +56 -17
- package/src/self-host-compose.ts +344 -68
- package/src/service-role-check.ts +111 -0
- package/src/target-client.ts +105 -18
- package/src/type-extractor.ts +883 -28
- package/src/ui/runtime/command-chrome.ts +1 -1
- package/src/ui/runtime/stdin-after-ink.ts +3 -1
- package/tests/access-composition.test.ts +667 -0
- package/tests/admin-studio-membership.test.ts +121 -0
- package/tests/api-schemas.test.ts +83 -0
- package/tests/archive-name-alias.test.ts +42 -0
- package/tests/augmentation-generator.test.ts +72 -4
- package/tests/cache-clean.test.ts +206 -0
- package/tests/cli-help.test.ts +3 -0
- package/tests/cli-install-method.test.ts +66 -0
- package/tests/command-chrome-coverage.test.ts +31 -26
- package/tests/command-chrome-wrap.test.ts +1 -0
- package/tests/db-preflight.test.ts +72 -0
- package/tests/doctor-render.test.ts +62 -0
- package/tests/doctor-service-role.test.ts +55 -0
- package/tests/env-file.test.ts +121 -0
- package/tests/external-database-compose.test.ts +184 -0
- package/tests/external-database-config.test.ts +154 -0
- package/tests/field-masking-tier.test.ts +184 -0
- package/tests/functions-deno-types.test.ts +197 -0
- package/tests/homebrew-formula.test.ts +58 -0
- package/tests/hook-upload.test.ts +125 -0
- package/tests/hooks-generator.test.ts +212 -0
- package/tests/init.test.ts +3 -0
- package/tests/local-secrets.test.ts +92 -0
- package/tests/model-hooks.test.ts +322 -0
- package/tests/runtime-contract.test.ts +113 -1
- package/tests/service-role-check.test.ts +109 -0
- package/tests/service-role-routes.test.ts +51 -0
- package/tests/target-client-refresh.test.ts +94 -0
- package/tests/type-extractor.test.ts +187 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tests/engine-distribution.test.ts +0 -418
|
@@ -90,7 +90,7 @@ export function isCommandChromeActive(): boolean {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** Commands that mount their own Ink UI — do not wrap with command chrome. */
|
|
93
|
-
const WIZARD_COMMAND_PATHS = new Set(["dev", "init", "link"])
|
|
93
|
+
const WIZARD_COMMAND_PATHS = new Set(["dev", "init", "link", "login", "_postinstall"])
|
|
94
94
|
|
|
95
95
|
/** Prefixes for subcommands that call `runClackFlow` internally. */
|
|
96
96
|
const WIZARD_COMMAND_PREFIXES = ["add "]
|
|
@@ -13,6 +13,8 @@ export function restoreStdinAfterInk(): void {
|
|
|
13
13
|
// ignore — stdin may already be restored
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Pause so one-shot Ink flows (cache list, keys, …) can exit; resume() would keep the
|
|
17
|
+
// process alive waiting for stdin on Windows Git Bash / MINGW64.
|
|
18
|
+
stdin.pause()
|
|
17
19
|
process.stdout.write("\n")
|
|
18
20
|
}
|
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
5
|
+
import type { ModelAstV2 } from "../src/schema-ast-v2.js"
|
|
6
|
+
import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
|
|
7
|
+
|
|
8
|
+
const dirs: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function extract(source: string, label: string): ModelAstV2 | undefined {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), `supatype-access-${label}-`))
|
|
16
|
+
dirs.push(dir)
|
|
17
|
+
const schemaPath = join(dir, "schema.ts")
|
|
18
|
+
writeFileSync(schemaPath, source, "utf8")
|
|
19
|
+
return extractSchemaAstFromTypes(schemaPath, dir)?.models.find((m) => m.name === "Post")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function access(model: ModelAstV2 | undefined): Record<string, unknown> {
|
|
23
|
+
return model?.annotations.platform.access ?? {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Without OR-composition the rule set is a closed list of single shapes, and the
|
|
27
|
+
// commonest real requirement — "an admin, or the owner" — cannot be written at all.
|
|
28
|
+
describe("Any<[…]>", () => {
|
|
29
|
+
it("composes rules into an OR-join", () => {
|
|
30
|
+
const post = extract(
|
|
31
|
+
`
|
|
32
|
+
import type { Model, UUID, Any, Role, Owner, LoggedIn } from "@supatype/types"
|
|
33
|
+
|
|
34
|
+
export type Post = Model<{
|
|
35
|
+
id: UUID
|
|
36
|
+
title: string
|
|
37
|
+
}, {
|
|
38
|
+
access: { update: Any<[Role<"admin">, Owner<"author_id">, LoggedIn]> }
|
|
39
|
+
}>
|
|
40
|
+
`,
|
|
41
|
+
"any",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
expect(access(post)["update"]).toEqual({
|
|
45
|
+
type: "any",
|
|
46
|
+
rules: [
|
|
47
|
+
{ type: "role", roles: ["admin"] },
|
|
48
|
+
{ type: "owner", field: "author_id" },
|
|
49
|
+
{ type: "authenticated" },
|
|
50
|
+
],
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("nests", () => {
|
|
55
|
+
const post = extract(
|
|
56
|
+
`
|
|
57
|
+
import type { Model, UUID, Any, Role, Owner } from "@supatype/types"
|
|
58
|
+
|
|
59
|
+
export type Post = Model<{
|
|
60
|
+
id: UUID
|
|
61
|
+
}, {
|
|
62
|
+
access: { read: Any<[Role<"admin">, Any<[Role<"editor">, Owner<"author_id">]>]> }
|
|
63
|
+
}>
|
|
64
|
+
`,
|
|
65
|
+
"any-nested",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
expect(access(post)["read"]).toEqual({
|
|
69
|
+
type: "any",
|
|
70
|
+
rules: [
|
|
71
|
+
{ type: "role", roles: ["admin"] },
|
|
72
|
+
{
|
|
73
|
+
type: "any",
|
|
74
|
+
rules: [
|
|
75
|
+
{ type: "role", roles: ["editor"] },
|
|
76
|
+
{ type: "owner", field: "author_id" },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// An empty list reads like a grant and compiles to a policy that grants
|
|
84
|
+
// nothing — exactly the silent denial the unknown-rule error exists to prevent.
|
|
85
|
+
it("rejects an empty list", () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
extract(
|
|
88
|
+
`
|
|
89
|
+
import type { Model, UUID, Any } from "@supatype/types"
|
|
90
|
+
export type Post = Model<{ id: UUID }, { access: { read: Any<[]> } }>
|
|
91
|
+
`,
|
|
92
|
+
"any-empty",
|
|
93
|
+
),
|
|
94
|
+
).toThrow(/grants nothing/)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("rejects a non-tuple argument", () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
extract(
|
|
100
|
+
`
|
|
101
|
+
import type { Model, UUID, Any, Role } from "@supatype/types"
|
|
102
|
+
export type Post = Model<{ id: UUID }, { access: { read: Any<Role<"admin">> } }>
|
|
103
|
+
`,
|
|
104
|
+
"any-nontuple",
|
|
105
|
+
),
|
|
106
|
+
).toThrow(/tuple of rules/)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("All<[…]> and Not<>", () => {
|
|
111
|
+
it("composes an AND-join and a negation", () => {
|
|
112
|
+
const post = extract(
|
|
113
|
+
`
|
|
114
|
+
import type { Model, UUID, All, Not, Role, NotNull } from "@supatype/types"
|
|
115
|
+
|
|
116
|
+
export type Post = Model<{
|
|
117
|
+
id: UUID
|
|
118
|
+
}, {
|
|
119
|
+
access: { read: All<[Role<"editor">, NotNull<"published_at">, Not<Role<"banned">>]> }
|
|
120
|
+
}>
|
|
121
|
+
`,
|
|
122
|
+
"all",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
expect(access(post)["read"]).toEqual({
|
|
126
|
+
type: "all",
|
|
127
|
+
rules: [
|
|
128
|
+
{ type: "role", roles: ["editor"] },
|
|
129
|
+
{ type: "nullCheck", operand: { kind: "column", name: "published_at" }, isNull: false },
|
|
130
|
+
{ type: "not", rule: { type: "role", roles: ["banned"] } },
|
|
131
|
+
],
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// An empty AND is *true*: it reads as a restriction while imposing none, which
|
|
136
|
+
// is the opposite failure from `Any<[]>` and just as silent.
|
|
137
|
+
it("rejects an empty list", () => {
|
|
138
|
+
expect(() =>
|
|
139
|
+
extract(
|
|
140
|
+
`
|
|
141
|
+
import type { Model, UUID, All } from "@supatype/types"
|
|
142
|
+
export type Post = Model<{ id: UUID }, { access: { read: All<[]> } }>
|
|
143
|
+
`,
|
|
144
|
+
"all-empty",
|
|
145
|
+
),
|
|
146
|
+
).toThrow(/grants everything/)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe("comparisons", () => {
|
|
151
|
+
it("treats a bare string as a column and Literal<> as a constant", () => {
|
|
152
|
+
const post = extract(
|
|
153
|
+
`
|
|
154
|
+
import type { Model, UUID, All, Eq, Gte, Literal, AuthUid, AuthRole, Claim } from "@supatype/types"
|
|
155
|
+
|
|
156
|
+
export type Post = Model<{
|
|
157
|
+
id: UUID
|
|
158
|
+
}, {
|
|
159
|
+
access: {
|
|
160
|
+
read: All<[
|
|
161
|
+
Eq<"author_id", AuthUid>,
|
|
162
|
+
Eq<"status", Literal<"published">>,
|
|
163
|
+
Eq<AuthRole, Literal<"admin">>,
|
|
164
|
+
Eq<"tier", Claim<"app_metadata.tier">>,
|
|
165
|
+
Gte<"views", Literal<10>>
|
|
166
|
+
]>
|
|
167
|
+
}
|
|
168
|
+
}>
|
|
169
|
+
`,
|
|
170
|
+
"compare",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
expect(access(post)["read"]).toEqual({
|
|
174
|
+
type: "all",
|
|
175
|
+
rules: [
|
|
176
|
+
{ type: "compare", op: "eq", left: { kind: "column", name: "author_id" }, right: { kind: "authUid" } },
|
|
177
|
+
{
|
|
178
|
+
type: "compare",
|
|
179
|
+
op: "eq",
|
|
180
|
+
left: { kind: "column", name: "status" },
|
|
181
|
+
right: { kind: "literal", value: "published" },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
type: "compare",
|
|
185
|
+
op: "eq",
|
|
186
|
+
left: { kind: "authRole" },
|
|
187
|
+
right: { kind: "literal", value: "admin" },
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
type: "compare",
|
|
191
|
+
op: "eq",
|
|
192
|
+
left: { kind: "column", name: "tier" },
|
|
193
|
+
right: { kind: "claim", path: "app_metadata.tier" },
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
type: "compare",
|
|
197
|
+
op: "gte",
|
|
198
|
+
left: { kind: "column", name: "views" },
|
|
199
|
+
right: { kind: "literal", value: 10 },
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("extracts IsNull separately from equality", () => {
|
|
206
|
+
const post = extract(
|
|
207
|
+
`
|
|
208
|
+
import type { Model, UUID, IsNull } from "@supatype/types"
|
|
209
|
+
export type Post = Model<{ id: UUID }, { access: { read: IsNull<"deleted_at"> } }>
|
|
210
|
+
`,
|
|
211
|
+
"isnull",
|
|
212
|
+
)
|
|
213
|
+
expect(access(post)["read"]).toEqual({
|
|
214
|
+
type: "nullCheck",
|
|
215
|
+
operand: { kind: "column", name: "deleted_at" },
|
|
216
|
+
isNull: true,
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// A bare string becomes a SQL identifier, so anything that is not one has to be
|
|
221
|
+
// refused rather than reaching the policy.
|
|
222
|
+
it("rejects a bare string that is not a column name", () => {
|
|
223
|
+
expect(() =>
|
|
224
|
+
extract(
|
|
225
|
+
`
|
|
226
|
+
import type { Model, UUID, Eq, AuthUid } from "@supatype/types"
|
|
227
|
+
export type Post = Model<{ id: UUID }, { access: { read: Eq<"a b'; drop", AuthUid> } }>
|
|
228
|
+
`,
|
|
229
|
+
"compare-badcol",
|
|
230
|
+
),
|
|
231
|
+
).toThrow(/not a valid column name/)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("rejects a malformed claim path", () => {
|
|
235
|
+
expect(() =>
|
|
236
|
+
extract(
|
|
237
|
+
`
|
|
238
|
+
import type { Model, UUID, Eq, Claim } from "@supatype/types"
|
|
239
|
+
export type Post = Model<{ id: UUID }, { access: { read: Eq<"tier", Claim<"app_metadata..tier">> } }>
|
|
240
|
+
`,
|
|
241
|
+
"compare-badclaim",
|
|
242
|
+
),
|
|
243
|
+
).toThrow(/not a valid claim path/)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("rejects an unknown operand", () => {
|
|
247
|
+
expect(() =>
|
|
248
|
+
extract(
|
|
249
|
+
`
|
|
250
|
+
import type { Model, UUID, Eq, Public } from "@supatype/types"
|
|
251
|
+
export type Post = Model<{ id: UUID }, { access: { read: Eq<"a", Public> } }>
|
|
252
|
+
`,
|
|
253
|
+
"compare-badoperand",
|
|
254
|
+
),
|
|
255
|
+
).toThrow(/not a valid operand/)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe("In<> membership", () => {
|
|
260
|
+
it("compiles a Rows<> source with its narrowing rule", () => {
|
|
261
|
+
const post = extract(
|
|
262
|
+
`
|
|
263
|
+
import type { Model, UUID, In, Rows, Eq, AuthUid } from "@supatype/types"
|
|
264
|
+
|
|
265
|
+
export type Post = Model<{
|
|
266
|
+
id: UUID
|
|
267
|
+
}, {
|
|
268
|
+
access: { read: In<"site_id", Rows<"user_sites", "site_id", Eq<"user_id", AuthUid>>> }
|
|
269
|
+
}>
|
|
270
|
+
`,
|
|
271
|
+
"in-rows",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
expect(access(post)["read"]).toEqual({
|
|
275
|
+
type: "in",
|
|
276
|
+
column: "site_id",
|
|
277
|
+
source: {
|
|
278
|
+
kind: "rows",
|
|
279
|
+
table: "user_sites",
|
|
280
|
+
column: "site_id",
|
|
281
|
+
where: {
|
|
282
|
+
type: "compare",
|
|
283
|
+
op: "eq",
|
|
284
|
+
left: { kind: "column", name: "user_id" },
|
|
285
|
+
right: { kind: "authUid" },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it("accepts a claim array and a fixed set", () => {
|
|
292
|
+
const post = extract(
|
|
293
|
+
`
|
|
294
|
+
import type { Model, UUID, Any, In, Claim, Values } from "@supatype/types"
|
|
295
|
+
|
|
296
|
+
export type Post = Model<{
|
|
297
|
+
id: UUID
|
|
298
|
+
}, {
|
|
299
|
+
access: {
|
|
300
|
+
read: Any<[
|
|
301
|
+
In<"site_id", Claim<"app_metadata.sites">>,
|
|
302
|
+
In<"status", Values<["published", "archived"]>>
|
|
303
|
+
]>
|
|
304
|
+
}
|
|
305
|
+
}>
|
|
306
|
+
`,
|
|
307
|
+
"in-claim",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
expect(access(post)["read"]).toEqual({
|
|
311
|
+
type: "any",
|
|
312
|
+
rules: [
|
|
313
|
+
{
|
|
314
|
+
type: "in",
|
|
315
|
+
column: "site_id",
|
|
316
|
+
source: { kind: "claim", path: "app_metadata.sites" },
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
type: "in",
|
|
320
|
+
column: "status",
|
|
321
|
+
source: { kind: "literal", values: ["published", "archived"] },
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("rejects a source it cannot resolve", () => {
|
|
328
|
+
expect(() =>
|
|
329
|
+
extract(
|
|
330
|
+
`
|
|
331
|
+
import type { Model, UUID, In, Public } from "@supatype/types"
|
|
332
|
+
export type Post = Model<{ id: UUID }, { access: { read: In<"a", Public> } }>
|
|
333
|
+
`,
|
|
334
|
+
"in-badsource",
|
|
335
|
+
),
|
|
336
|
+
).toThrow(/not a valid membership source/)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it("rejects a table name that is not an identifier", () => {
|
|
340
|
+
expect(() =>
|
|
341
|
+
extract(
|
|
342
|
+
`
|
|
343
|
+
import type { Model, UUID, In, Rows } from "@supatype/types"
|
|
344
|
+
export type Post = Model<{ id: UUID }, { access: { read: In<"a", Rows<"a b; drop", "c">> } }>
|
|
345
|
+
`,
|
|
346
|
+
"in-badtable",
|
|
347
|
+
),
|
|
348
|
+
).toThrow(/not a valid table name/)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// The plan's headline parameterisation: the type-level equivalent of Payload's
|
|
353
|
+
// access-control factory. Before this, any named alias — even unparameterised —
|
|
354
|
+
// was reported as an unknown rule and had to be written inline at every use.
|
|
355
|
+
describe("parameterised rule aliases", () => {
|
|
356
|
+
it("expands a generic alias at the point of use", () => {
|
|
357
|
+
const post = extract(
|
|
358
|
+
`
|
|
359
|
+
import type { Model, UUID, Any, All, Role, In, Rows, Eq, AuthUid } from "@supatype/types"
|
|
360
|
+
|
|
361
|
+
type MySites = Rows<"user_sites", "site_id", Eq<"user_id", AuthUid>>
|
|
362
|
+
type SiteAccess<Field extends string> = Any<[Role<"admin">, All<[Role<"editor">, In<Field, MySites>]>]>
|
|
363
|
+
|
|
364
|
+
export type Post = Model<{
|
|
365
|
+
id: UUID
|
|
366
|
+
}, {
|
|
367
|
+
access: { update: SiteAccess<"site_id"> }
|
|
368
|
+
}>
|
|
369
|
+
`,
|
|
370
|
+
"alias",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
expect(access(post)["update"]).toEqual({
|
|
374
|
+
type: "any",
|
|
375
|
+
rules: [
|
|
376
|
+
{ type: "role", roles: ["admin"] },
|
|
377
|
+
{
|
|
378
|
+
type: "all",
|
|
379
|
+
rules: [
|
|
380
|
+
{ type: "role", roles: ["editor"] },
|
|
381
|
+
{
|
|
382
|
+
type: "in",
|
|
383
|
+
column: "site_id",
|
|
384
|
+
source: {
|
|
385
|
+
kind: "rows",
|
|
386
|
+
table: "user_sites",
|
|
387
|
+
column: "site_id",
|
|
388
|
+
where: {
|
|
389
|
+
type: "compare",
|
|
390
|
+
op: "eq",
|
|
391
|
+
left: { kind: "column", name: "user_id" },
|
|
392
|
+
right: { kind: "authUid" },
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
// An update policy has two halves: USING picks which rows may be modified, WITH
|
|
404
|
+
// CHECK constrains what they may become. One rule for both is the right default,
|
|
405
|
+
// but it makes "an editor may move a post between their own sites" inexpressible.
|
|
406
|
+
describe("update: { using, check }", () => {
|
|
407
|
+
it("splits row selection from the result constraint", () => {
|
|
408
|
+
const post = extract(
|
|
409
|
+
`
|
|
410
|
+
import type { Model, UUID, In, Rows, Eq, AuthUid, Owner } from "@supatype/types"
|
|
411
|
+
|
|
412
|
+
type MySites = Rows<"user_sites", "site_id", Eq<"user_id", AuthUid>>
|
|
413
|
+
|
|
414
|
+
export type Post = Model<{
|
|
415
|
+
id: UUID
|
|
416
|
+
}, {
|
|
417
|
+
access: {
|
|
418
|
+
update: { using: Owner<"author_id">; check: In<"site_id", MySites> }
|
|
419
|
+
}
|
|
420
|
+
}>
|
|
421
|
+
`,
|
|
422
|
+
"update-split",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
expect(access(post)["update"]).toEqual({ type: "owner", field: "author_id" })
|
|
426
|
+
expect(access(post)["updateCheck"]).toEqual({
|
|
427
|
+
type: "in",
|
|
428
|
+
column: "site_id",
|
|
429
|
+
source: {
|
|
430
|
+
kind: "rows",
|
|
431
|
+
table: "user_sites",
|
|
432
|
+
column: "site_id",
|
|
433
|
+
where: {
|
|
434
|
+
type: "compare",
|
|
435
|
+
op: "eq",
|
|
436
|
+
left: { kind: "column", name: "user_id" },
|
|
437
|
+
right: { kind: "authUid" },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// The plain form is untouched: no `updateCheck`, so the engine uses one rule for
|
|
444
|
+
// both halves, which is what every existing schema means.
|
|
445
|
+
it("leaves a plain rule alone", () => {
|
|
446
|
+
const post = extract(
|
|
447
|
+
`
|
|
448
|
+
import type { Model, UUID, Owner } from "@supatype/types"
|
|
449
|
+
export type Post = Model<{ id: UUID }, { access: { update: Owner<"author_id"> } }>
|
|
450
|
+
`,
|
|
451
|
+
"update-plain",
|
|
452
|
+
)
|
|
453
|
+
expect(access(post)["update"]).toEqual({ type: "owner", field: "author_id" })
|
|
454
|
+
expect(access(post)["updateCheck"]).toBeUndefined()
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it("rejects a check with no using", () => {
|
|
458
|
+
expect(() =>
|
|
459
|
+
extract(
|
|
460
|
+
`
|
|
461
|
+
import type { Model, UUID, Public } from "@supatype/types"
|
|
462
|
+
export type Post = Model<{ id: UUID }, { access: { update: { check: Public } } }>
|
|
463
|
+
`,
|
|
464
|
+
"update-checkonly",
|
|
465
|
+
),
|
|
466
|
+
).toThrow(/needs a `using` rule/)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it("rejects an unknown key", () => {
|
|
470
|
+
expect(() =>
|
|
471
|
+
extract(
|
|
472
|
+
`
|
|
473
|
+
import type { Model, UUID, Public } from "@supatype/types"
|
|
474
|
+
export type Post = Model<{ id: UUID }, { access: { update: { using: Public; wat: Public } } }>
|
|
475
|
+
`,
|
|
476
|
+
"update-badkey",
|
|
477
|
+
),
|
|
478
|
+
).toThrow(/may only contain/)
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
// "Publish at 09:00 next Tuesday" is *data* — the row carries `published_at` and the
|
|
484
|
+
// rule is the same for every row. There is deliberately no literal-timestamp
|
|
485
|
+
// operand; the policy re-evaluates per query, so the row starts matching at 09:00
|
|
486
|
+
// with no cron and no publish worker.
|
|
487
|
+
describe("temporal operands", () => {
|
|
488
|
+
it("expresses the scheduled-publishing rule", () => {
|
|
489
|
+
const post = extract(
|
|
490
|
+
`
|
|
491
|
+
import type { Model, UUID, Any, All, Role, Lte, Gt, IsNull, Now } from "@supatype/types"
|
|
492
|
+
|
|
493
|
+
export type Post = Model<{
|
|
494
|
+
id: UUID
|
|
495
|
+
}, {
|
|
496
|
+
access: {
|
|
497
|
+
read: Any<[
|
|
498
|
+
Role<"editor">,
|
|
499
|
+
All<[Lte<"published_at", Now>, Any<[IsNull<"expires_at">, Gt<"expires_at", Now>]>]>
|
|
500
|
+
]>
|
|
501
|
+
}
|
|
502
|
+
}>
|
|
503
|
+
`,
|
|
504
|
+
"now",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
expect(access(post)["read"]).toEqual({
|
|
508
|
+
type: "any",
|
|
509
|
+
rules: [
|
|
510
|
+
{ type: "role", roles: ["editor"] },
|
|
511
|
+
{
|
|
512
|
+
type: "all",
|
|
513
|
+
rules: [
|
|
514
|
+
{
|
|
515
|
+
type: "compare",
|
|
516
|
+
op: "lte",
|
|
517
|
+
left: { kind: "column", name: "published_at" },
|
|
518
|
+
right: { kind: "now" },
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
type: "any",
|
|
522
|
+
rules: [
|
|
523
|
+
{ type: "nullCheck", operand: { kind: "column", name: "expires_at" }, isNull: true },
|
|
524
|
+
{
|
|
525
|
+
type: "compare",
|
|
526
|
+
op: "gt",
|
|
527
|
+
left: { kind: "column", name: "expires_at" },
|
|
528
|
+
right: { kind: "now" },
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it("extracts relative and truncated times", () => {
|
|
539
|
+
const post = extract(
|
|
540
|
+
`
|
|
541
|
+
import type { Model, UUID, All, Gte, Lte, Ago, FromNow, StartOf } from "@supatype/types"
|
|
542
|
+
|
|
543
|
+
export type Post = Model<{
|
|
544
|
+
id: UUID
|
|
545
|
+
}, {
|
|
546
|
+
access: {
|
|
547
|
+
read: All<[
|
|
548
|
+
Gte<"created_at", Ago<30, "days">>,
|
|
549
|
+
Lte<"starts_at", FromNow<7, "days">>,
|
|
550
|
+
Gte<"updated_at", StartOf<"week">>
|
|
551
|
+
]>
|
|
552
|
+
}
|
|
553
|
+
}>
|
|
554
|
+
`,
|
|
555
|
+
"durations",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
expect(access(post)["read"]).toEqual({
|
|
559
|
+
type: "all",
|
|
560
|
+
rules: [
|
|
561
|
+
{
|
|
562
|
+
type: "compare",
|
|
563
|
+
op: "gte",
|
|
564
|
+
left: { kind: "column", name: "created_at" },
|
|
565
|
+
right: { kind: "ago", amount: 30, unit: "days" },
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
type: "compare",
|
|
569
|
+
op: "lte",
|
|
570
|
+
left: { kind: "column", name: "starts_at" },
|
|
571
|
+
right: { kind: "fromNow", amount: 7, unit: "days" },
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
type: "compare",
|
|
575
|
+
op: "gte",
|
|
576
|
+
left: { kind: "column", name: "updated_at" },
|
|
577
|
+
right: { kind: "startOf", unit: "week" },
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
})
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
// A permissive `"30 days"` string would be raw SQL by another name, so the amount
|
|
584
|
+
// and unit are validated separately and the interval is reassembled from them.
|
|
585
|
+
it("rejects a fractional or negative amount", () => {
|
|
586
|
+
const bad = (n: string) => `
|
|
587
|
+
import type { Model, UUID, Gte, Ago } from "@supatype/types"
|
|
588
|
+
export type Post = Model<{ id: UUID }, { access: { read: Gte<"a", Ago<${n}, "days">> } }>
|
|
589
|
+
`
|
|
590
|
+
expect(() => extract(bad("0.5"), "ago-frac")).toThrow(/whole number/)
|
|
591
|
+
// A negative literal is a prefix-unary expression, not a numeric literal, so it
|
|
592
|
+
// needs its own branch — otherwise the message misses the real advice.
|
|
593
|
+
expect(() => extract(bad("-5"), "ago-neg")).toThrow(/does not take a negative amount/)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it("rejects an unknown unit", () => {
|
|
597
|
+
expect(() =>
|
|
598
|
+
extract(
|
|
599
|
+
`
|
|
600
|
+
import type { Model, UUID, Gte, Ago } from "@supatype/types"
|
|
601
|
+
export type Post = Model<{ id: UUID }, { access: { read: Gte<"a", Ago<30, "fortnights">> } }>
|
|
602
|
+
`,
|
|
603
|
+
"ago-unit",
|
|
604
|
+
),
|
|
605
|
+
).toThrow(/not a valid unit/)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it("rejects an unknown StartOf granularity", () => {
|
|
609
|
+
expect(() =>
|
|
610
|
+
extract(
|
|
611
|
+
`
|
|
612
|
+
import type { Model, UUID, Gte, StartOf } from "@supatype/types"
|
|
613
|
+
export type Post = Model<{ id: UUID }, { access: { read: Gte<"a", StartOf<"fortnight">> } }>
|
|
614
|
+
`,
|
|
615
|
+
"startof-unit",
|
|
616
|
+
),
|
|
617
|
+
).toThrow(/not a valid granularity/)
|
|
618
|
+
})
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
// `In<>` asks "is this row's value in the set". Payload's site-access rule guards on
|
|
622
|
+
// `user.sites?.length > 0` first — without that, an editor with no sites still
|
|
623
|
+
// matches the unassigned-rows branch.
|
|
624
|
+
describe("Exists<> non-empty guard", () => {
|
|
625
|
+
it("expresses the Payload site-access rule in full", () => {
|
|
626
|
+
const post = extract(
|
|
627
|
+
`
|
|
628
|
+
import type { Model, UUID, Any, All, Role, In, Exists, IsNull, Rows, Eq, AuthUid } from "@supatype/types"
|
|
629
|
+
|
|
630
|
+
type MySites = Rows<"user_sites", "site_id", Eq<"user_id", AuthUid>>
|
|
631
|
+
|
|
632
|
+
export type Post = Model<{
|
|
633
|
+
id: UUID
|
|
634
|
+
}, {
|
|
635
|
+
access: {
|
|
636
|
+
read: Any<[
|
|
637
|
+
Role<"admin">,
|
|
638
|
+
All<[
|
|
639
|
+
Role<"editor">,
|
|
640
|
+
Exists<MySites>,
|
|
641
|
+
Any<[In<"site_id", MySites>, IsNull<"site_id">]>
|
|
642
|
+
]>
|
|
643
|
+
]>
|
|
644
|
+
}
|
|
645
|
+
}>
|
|
646
|
+
`,
|
|
647
|
+
"exists",
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
const read = access(post)["read"] as { rules: Record<string, unknown>[] }
|
|
651
|
+
const editorBranch = read.rules[1] as { rules: Record<string, unknown>[] }
|
|
652
|
+
expect(editorBranch.rules[1]).toEqual({
|
|
653
|
+
type: "exists",
|
|
654
|
+
source: {
|
|
655
|
+
kind: "rows",
|
|
656
|
+
table: "user_sites",
|
|
657
|
+
column: "site_id",
|
|
658
|
+
where: {
|
|
659
|
+
type: "compare",
|
|
660
|
+
op: "eq",
|
|
661
|
+
left: { kind: "column", name: "user_id" },
|
|
662
|
+
right: { kind: "authUid" },
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
})
|