@usegraft/registry 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/LICENSE +21 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +365 -0
- package/package.json +41 -0
- package/registry/callout/components/Callout.tsx +34 -0
- package/registry/callout/components/callout.llms.txt +19 -0
- package/registry/callout/editor/Callout.json +15 -0
- package/registry/callout/registry.item.json +13 -0
- package/registry/comments/graft/comments.llms.txt +14 -0
- package/registry/comments/graft/comments.ts +113 -0
- package/registry/comments/registry.item.json +10 -0
- package/registry/commerce/graft/commerce.llms.txt +35 -0
- package/registry/commerce/graft/commerce.ts +294 -0
- package/registry/commerce/registry.item.json +13 -0
- package/registry/faq/components/Faq.tsx +38 -0
- package/registry/faq/components/faq.llms.txt +22 -0
- package/registry/faq/editor/Faq.json +5 -0
- package/registry/faq/graft/fields/faq.ts +21 -0
- package/registry/faq/registry.item.json +14 -0
- package/registry/scoped-access/graft/scoped-access.llms.txt +9 -0
- package/registry/scoped-access/graft/scoped-access.ts +19 -0
- package/registry/scoped-access/registry.item.json +14 -0
- package/registry/seo/graft/fields/seo.llms.txt +19 -0
- package/registry/seo/graft/fields/seo.ts +24 -0
- package/registry/seo/registry.item.json +10 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"component": "Callout",
|
|
3
|
+
"label": "Callout",
|
|
4
|
+
"titleProp": "title",
|
|
5
|
+
"tone": {
|
|
6
|
+
"prop": "type",
|
|
7
|
+
"map": {
|
|
8
|
+
"info": "info",
|
|
9
|
+
"warning": "warn",
|
|
10
|
+
"tip": "success"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"hideProps": ["type"],
|
|
14
|
+
"snippet": "<Callout type=\"info\">\nSomething worth pulling out of the paragraph.\n</Callout>"
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "callout",
|
|
3
|
+
"type": "block",
|
|
4
|
+
"description": "MDX Callout block — highlight info, warning, or tip callouts in authored content.",
|
|
5
|
+
"graftVersion": "*",
|
|
6
|
+
"dependencies": {},
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"files": [
|
|
9
|
+
{ "source": "components/Callout.tsx", "target": "components/Callout.tsx", "role": "component" },
|
|
10
|
+
{ "source": "editor/Callout.json", "target": "graft/editor/Callout.json", "role": "editor" }
|
|
11
|
+
],
|
|
12
|
+
"llms": "components/callout.llms.txt"
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
## comments (primitive)
|
|
2
|
+
|
|
3
|
+
Moderated comments backed by db-authoritative rows (`graft/comments.ts`).
|
|
4
|
+
Functions are served at POST /api/fn/<name>:
|
|
5
|
+
|
|
6
|
+
- `postComment` — public; `{ pageSlug, author, body }` → stores it unapproved. 5/min per caller.
|
|
7
|
+
- `listComments` — public; `{ pageSlug, limit? }` → approved comments, newest first.
|
|
8
|
+
- `moderateComment` — `{ id, approved }` → approve/hide. Needs the `content:moderate` scope.
|
|
9
|
+
- `deleteComment` — `{ id }` → destructive: files an approval (`graft approve <id>`), then
|
|
10
|
+
retry with the approval id (MCP: the `approval` argument; HTTP: the
|
|
11
|
+
`x-graft-approval` header); also needs `content:moderate`.
|
|
12
|
+
|
|
13
|
+
New comments are hidden until `moderateComment` approves them. Depends on the
|
|
14
|
+
`scoped-access` primitive (`graft/scoped-access.ts`) for the moderation gate.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* comments — a moderated-comments primitive (owned code; edit freely).
|
|
3
|
+
*
|
|
4
|
+
* A db-authoritative collection plus its functions: post (public), list
|
|
5
|
+
* (public; approved only), moderate (approve/hide; scope-gated), and delete
|
|
6
|
+
* (destructive; human-gated). Wired automatically via the graft/ barrel — live
|
|
7
|
+
* on the next `graft compile`. See graft/comments.llms.txt for the flow.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
defineCollection,
|
|
11
|
+
defineFunction,
|
|
12
|
+
deleteRecord,
|
|
13
|
+
field,
|
|
14
|
+
insertRecord,
|
|
15
|
+
listRecords,
|
|
16
|
+
updateRecord,
|
|
17
|
+
} from "@usegraft/core";
|
|
18
|
+
import { requireModerator } from "./scoped-access";
|
|
19
|
+
|
|
20
|
+
export const comments = defineCollection({
|
|
21
|
+
name: "comments",
|
|
22
|
+
authority: "db-authoritative",
|
|
23
|
+
description: "Visitor comments, held for moderation until approved.",
|
|
24
|
+
fields: {
|
|
25
|
+
pageSlug: field.string({ description: "Slug of the page the comment belongs to." }),
|
|
26
|
+
author: field.string({ description: "Commenter's display name." }),
|
|
27
|
+
body: field.text({ description: "The comment text." }),
|
|
28
|
+
approved: field.boolean({ description: "Only approved comments list publicly." }),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Public: anyone may post; the comment is held unapproved until moderated. */
|
|
33
|
+
export const postComment = defineFunction({
|
|
34
|
+
name: "postComment",
|
|
35
|
+
kind: "mutation",
|
|
36
|
+
public: true,
|
|
37
|
+
rateLimit: { limit: 5, windowSeconds: 60 },
|
|
38
|
+
description:
|
|
39
|
+
"Post a comment (public; held unapproved until a moderator approves it). 5/min per caller.",
|
|
40
|
+
returns: "{ id: string; receivedAt: string }",
|
|
41
|
+
input: {
|
|
42
|
+
pageSlug: field.string({ description: "Page the comment is on." }),
|
|
43
|
+
author: field.string({ description: "Display name." }),
|
|
44
|
+
body: field.text({ description: "The comment text." }),
|
|
45
|
+
},
|
|
46
|
+
handler: async (ctx) => {
|
|
47
|
+
const record = await insertRecord(ctx, comments, { ...ctx.input, approved: false });
|
|
48
|
+
return { id: record.id, receivedAt: record.createdAt.toISOString() };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Public: only approved comments for the page, newest first. */
|
|
53
|
+
export const listComments = defineFunction({
|
|
54
|
+
name: "listComments",
|
|
55
|
+
kind: "query",
|
|
56
|
+
description: "List approved comments for a page, newest first.",
|
|
57
|
+
returns: "{ comments: { id: string; author: string; body: string; receivedAt: string }[] }",
|
|
58
|
+
input: {
|
|
59
|
+
pageSlug: field.string({ description: "Page to list comments for." }),
|
|
60
|
+
limit: field.number({ optional: true, description: "Max rows scanned (default 100)." }),
|
|
61
|
+
},
|
|
62
|
+
handler: async (ctx) => {
|
|
63
|
+
const records = await listRecords(ctx, comments, { limit: ctx.input.limit ?? 100 });
|
|
64
|
+
return {
|
|
65
|
+
comments: records
|
|
66
|
+
.filter((r) => r.data.approved && r.data.pageSlug === ctx.input.pageSlug)
|
|
67
|
+
.map((r) => ({
|
|
68
|
+
id: r.id,
|
|
69
|
+
author: r.data.author,
|
|
70
|
+
body: r.data.body,
|
|
71
|
+
receivedAt: r.createdAt.toISOString(),
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Scope-gated: approve or hide a comment. Needs the content:moderate scope. */
|
|
78
|
+
export const moderateComment = defineFunction({
|
|
79
|
+
name: "moderateComment",
|
|
80
|
+
kind: "mutation",
|
|
81
|
+
description: "Approve or hide a comment. Requires the content:moderate scope.",
|
|
82
|
+
returns: "{ id: string; approved: boolean }",
|
|
83
|
+
input: {
|
|
84
|
+
id: field.string({ description: "The comment row id (uuid)." }),
|
|
85
|
+
approved: field.boolean({ description: "true to approve (publish), false to hide." }),
|
|
86
|
+
},
|
|
87
|
+
access: requireModerator,
|
|
88
|
+
handler: async (ctx) => {
|
|
89
|
+
const record = await updateRecord(ctx, comments, ctx.input.id, {
|
|
90
|
+
approved: ctx.input.approved,
|
|
91
|
+
});
|
|
92
|
+
return { id: record.id, approved: record.data.approved };
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** Destructive + scope-gated: permanently delete a comment (human-gated). */
|
|
97
|
+
export const deleteComment = defineFunction({
|
|
98
|
+
name: "deleteComment",
|
|
99
|
+
kind: "mutation",
|
|
100
|
+
destructive: true,
|
|
101
|
+
description:
|
|
102
|
+
"Permanently delete a comment. Destructive: requires human approval (graft approve) and the content:moderate scope.",
|
|
103
|
+
returns: "{ deleted: { id: string } }",
|
|
104
|
+
input: { id: field.string({ description: "The comment row id (uuid)." }) },
|
|
105
|
+
access: requireModerator,
|
|
106
|
+
handler: async (ctx) => {
|
|
107
|
+
const removed = await deleteRecord(ctx, comments, ctx.input.id);
|
|
108
|
+
return { deleted: { id: removed.id } };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export const collections = { comments };
|
|
113
|
+
export const functions = { postComment, listComments, moderateComment, deleteComment };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "comments",
|
|
3
|
+
"type": "bundle",
|
|
4
|
+
"description": "Moderated comments: a db-authoritative collection + post/list/moderate/delete functions.",
|
|
5
|
+
"graftVersion": "*",
|
|
6
|
+
"dependencies": {},
|
|
7
|
+
"registryDependencies": ["scoped-access"],
|
|
8
|
+
"files": [{ "source": "graft/comments.ts", "target": "graft/comments.ts", "role": "module" }],
|
|
9
|
+
"llms": "graft/comments.llms.txt"
|
|
10
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
## commerce (primitive)
|
|
2
|
+
|
|
3
|
+
Catalog + orders foundation in `graft/commerce.ts`.
|
|
4
|
+
|
|
5
|
+
### Catalog (files)
|
|
6
|
+
|
|
7
|
+
Author products under `content/products/<slug>.mdx`:
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
title: Widget
|
|
11
|
+
description: A fine widget.
|
|
12
|
+
priceCents: 2900
|
|
13
|
+
currency: USD
|
|
14
|
+
active: true
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
Longer product body (MDX)…
|
|
18
|
+
|
|
19
|
+
Then `graft compile`. List via sdk `listContent("products")` or MCP `list_content`.
|
|
20
|
+
|
|
21
|
+
### Orders (Postgres)
|
|
22
|
+
|
|
23
|
+
Functions at POST /api/fn/<name>:
|
|
24
|
+
|
|
25
|
+
- `placeOrder` — public; `{ email, items: [{ productSlug, qty }] }` → creates a
|
|
26
|
+
`pending` order with snapshotted unit prices. Unknown/inactive products 400.
|
|
27
|
+
- `listOrders` — needs `commerce:orders:read`; `{ limit? }`.
|
|
28
|
+
- `updateOrderStatus` — needs `commerce:orders:write`; `{ id, status }` where
|
|
29
|
+
status is pending|paid|cancelled|fulfilled.
|
|
30
|
+
- `cancelOrder` — destructive hard-delete; needs write scope + `graft approve`
|
|
31
|
+
then retry with the approval id (MCP: the `approval` argument; HTTP: the
|
|
32
|
+
`x-graft-approval` header). Prefer `updateOrderStatus` → cancelled for
|
|
33
|
+
soft cancel.
|
|
34
|
+
|
|
35
|
+
Mint scopes on your issuer (example: GRAFT_DEV_TOKEN or Better Auth payload).
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commerce — catalog + orders foundation (owned code; edit freely).
|
|
3
|
+
*
|
|
4
|
+
* products — file-authoritative: author MDX under content/products/*.mdx
|
|
5
|
+
* orders — db-authoritative: rows in Postgres via the functions below
|
|
6
|
+
*
|
|
7
|
+
* Wired via the graft/ barrel — live on the next `graft compile`.
|
|
8
|
+
* See graft/commerce.llms.txt for the agent flow.
|
|
9
|
+
*/
|
|
10
|
+
import { requireScopes } from "@usegraft/auth";
|
|
11
|
+
import { GraftError } from "@usegraft/contracts";
|
|
12
|
+
import {
|
|
13
|
+
defineCollection,
|
|
14
|
+
defineFunction,
|
|
15
|
+
deleteRecord,
|
|
16
|
+
field,
|
|
17
|
+
insertRecord,
|
|
18
|
+
listRecords,
|
|
19
|
+
updateRecord,
|
|
20
|
+
type FunctionContext,
|
|
21
|
+
} from "@usegraft/core";
|
|
22
|
+
|
|
23
|
+
/** Scopes this vertical checks — point them at whatever your issuer emits. */
|
|
24
|
+
export const COMMERCE_SCOPES = {
|
|
25
|
+
ordersRead: "commerce:orders:read",
|
|
26
|
+
ordersWrite: "commerce:orders:write",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
const requireOrdersRead = requireScopes(COMMERCE_SCOPES.ordersRead);
|
|
30
|
+
const requireOrdersWrite = requireScopes(COMMERCE_SCOPES.ordersWrite);
|
|
31
|
+
|
|
32
|
+
export const ORDER_STATUSES = ["pending", "paid", "cancelled", "fulfilled"] as const;
|
|
33
|
+
export type OrderStatus = (typeof ORDER_STATUSES)[number];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* products — catalog as files. Agents author content/products/<slug>.mdx;
|
|
37
|
+
* git is the version history. Compile projects into content_index.
|
|
38
|
+
*/
|
|
39
|
+
export const products = defineCollection({
|
|
40
|
+
name: "products",
|
|
41
|
+
description: "Sellable catalog items (file-authoritative). Author under content/products/.",
|
|
42
|
+
fields: {
|
|
43
|
+
title: field.string({ description: "Product name." }),
|
|
44
|
+
description: field.text({ description: "Short product description." }),
|
|
45
|
+
priceCents: field.number({ description: "Price in the smallest currency unit (e.g. cents)." }),
|
|
46
|
+
currency: field.string({
|
|
47
|
+
optional: true,
|
|
48
|
+
description: 'ISO currency code (default "USD").',
|
|
49
|
+
}),
|
|
50
|
+
image: field.asset({ optional: true, description: "Product image." }),
|
|
51
|
+
active: field.boolean({
|
|
52
|
+
optional: true,
|
|
53
|
+
description: "When false, placeOrder rejects this product. Default true when omitted.",
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* orders — operational data. Written only through placeOrder / updateOrderStatus /
|
|
60
|
+
* cancelOrder. No content/orders/ folder (AUTHORITY_MISMATCH if files appear).
|
|
61
|
+
*/
|
|
62
|
+
export const orders = defineCollection({
|
|
63
|
+
name: "orders",
|
|
64
|
+
authority: "db-authoritative",
|
|
65
|
+
description: "Customer orders. Place via placeOrder; admin via list/update/cancel.",
|
|
66
|
+
fields: {
|
|
67
|
+
email: field.string({ description: "Buyer email." }),
|
|
68
|
+
items: field.array({
|
|
69
|
+
description: "Line items snapshotted at order time (price locked).",
|
|
70
|
+
of: field.object({
|
|
71
|
+
fields: {
|
|
72
|
+
productSlug: field.string({ description: "Catalog product slug." }),
|
|
73
|
+
qty: field.number({ description: "Quantity (>= 1)." }),
|
|
74
|
+
unitPriceCents: field.number({ description: "Unit price at order time." }),
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
}),
|
|
78
|
+
status: field.string({
|
|
79
|
+
description: "pending | paid | cancelled | fulfilled",
|
|
80
|
+
}),
|
|
81
|
+
totalCents: field.number({ description: "Sum of qty * unitPriceCents." }),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
type ProductRow = {
|
|
86
|
+
slug: string;
|
|
87
|
+
title: string;
|
|
88
|
+
priceCents: number;
|
|
89
|
+
active: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Load product frontmatter from content_index for the current branch. */
|
|
93
|
+
async function loadProducts(
|
|
94
|
+
ctx: Pick<FunctionContext, "db" | "branch">,
|
|
95
|
+
slugs: string[],
|
|
96
|
+
): Promise<Map<string, ProductRow>> {
|
|
97
|
+
const unique = [...new Set(slugs)];
|
|
98
|
+
const out = new Map<string, ProductRow>();
|
|
99
|
+
for (const slug of unique) {
|
|
100
|
+
const row = await ctx.db.query.contentIndex.findFirst({
|
|
101
|
+
where: (t, ops) =>
|
|
102
|
+
ops.and(
|
|
103
|
+
ops.eq(t.branchId, ctx.branch),
|
|
104
|
+
ops.eq(t.collection, "products"),
|
|
105
|
+
ops.eq(t.slug, slug),
|
|
106
|
+
ops.eq(t.deleted, false),
|
|
107
|
+
),
|
|
108
|
+
});
|
|
109
|
+
if (!row) continue;
|
|
110
|
+
const data = row.data as {
|
|
111
|
+
title?: string;
|
|
112
|
+
priceCents?: number;
|
|
113
|
+
active?: boolean;
|
|
114
|
+
};
|
|
115
|
+
if (typeof data.priceCents !== "number" || typeof data.title !== "string") continue;
|
|
116
|
+
out.set(slug, {
|
|
117
|
+
slug,
|
|
118
|
+
title: data.title,
|
|
119
|
+
priceCents: data.priceCents,
|
|
120
|
+
active: data.active !== false,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Public: place an order against the live catalog (prices snapshotted). */
|
|
127
|
+
export const placeOrder = defineFunction({
|
|
128
|
+
name: "placeOrder",
|
|
129
|
+
kind: "mutation",
|
|
130
|
+
public: true,
|
|
131
|
+
rateLimit: { limit: 10, windowSeconds: 60 },
|
|
132
|
+
description:
|
|
133
|
+
"Place an order for active products. Snapshots unit prices; status starts as pending. 10/min per caller.",
|
|
134
|
+
returns: "{ id: string; totalCents: number; status: string; receivedAt: string }",
|
|
135
|
+
input: {
|
|
136
|
+
email: field.string({ description: "Buyer email." }),
|
|
137
|
+
items: field.array({
|
|
138
|
+
description: "What to buy.",
|
|
139
|
+
of: field.object({
|
|
140
|
+
fields: {
|
|
141
|
+
productSlug: field.string({ description: "Product slug from content/products/." }),
|
|
142
|
+
qty: field.number({ description: "Quantity (>= 1)." }),
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
handler: async (ctx) => {
|
|
148
|
+
const { email, items } = ctx.input;
|
|
149
|
+
if (items.length === 0) {
|
|
150
|
+
throw new GraftError({
|
|
151
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
152
|
+
message: "placeOrder requires at least one line item.",
|
|
153
|
+
fix: 'Send items: [{ "productSlug": "widget", "qty": 1 }]. List catalog slugs via listContent("products") or content/products/.',
|
|
154
|
+
details: { issues: [{ path: "items", message: "must be non-empty" }] },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
for (const [i, line] of items.entries()) {
|
|
158
|
+
if (!Number.isFinite(line.qty) || line.qty < 1 || !Number.isInteger(line.qty)) {
|
|
159
|
+
throw new GraftError({
|
|
160
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
161
|
+
message: `items[${i}].qty must be an integer >= 1.`,
|
|
162
|
+
fix: "Use whole quantities of at least 1 per line.",
|
|
163
|
+
details: { issues: [{ path: `items.${i}.qty`, message: "integer >= 1 required" }] },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const catalog = await loadProducts(
|
|
169
|
+
ctx,
|
|
170
|
+
items.map((l) => l.productSlug),
|
|
171
|
+
);
|
|
172
|
+
const snapshotted: { productSlug: string; qty: number; unitPriceCents: number }[] = [];
|
|
173
|
+
let totalCents = 0;
|
|
174
|
+
|
|
175
|
+
for (const [i, line] of items.entries()) {
|
|
176
|
+
const product = catalog.get(line.productSlug);
|
|
177
|
+
if (!product) {
|
|
178
|
+
throw new GraftError({
|
|
179
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
180
|
+
message: `Unknown product slug "${line.productSlug}".`,
|
|
181
|
+
fix: `Author content/products/${line.productSlug}.mdx (or fix the slug), run graft compile, then retry.`,
|
|
182
|
+
details: {
|
|
183
|
+
issues: [{ path: `items.${i}.productSlug`, message: "product not found" }],
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (!product.active) {
|
|
188
|
+
throw new GraftError({
|
|
189
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
190
|
+
message: `Product "${line.productSlug}" is not active.`,
|
|
191
|
+
fix: "Set active: true on that product (or omit active) and recompile.",
|
|
192
|
+
details: {
|
|
193
|
+
issues: [{ path: `items.${i}.productSlug`, message: "product inactive" }],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
snapshotted.push({
|
|
198
|
+
productSlug: line.productSlug,
|
|
199
|
+
qty: line.qty,
|
|
200
|
+
unitPriceCents: product.priceCents,
|
|
201
|
+
});
|
|
202
|
+
totalCents += product.priceCents * line.qty;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const record = await insertRecord(ctx, orders, {
|
|
206
|
+
email,
|
|
207
|
+
items: snapshotted,
|
|
208
|
+
status: "pending",
|
|
209
|
+
totalCents,
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
id: record.id,
|
|
213
|
+
totalCents: record.data.totalCents,
|
|
214
|
+
status: record.data.status,
|
|
215
|
+
receivedAt: record.createdAt.toISOString(),
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/** Scope-gated: list recent orders, newest first. */
|
|
221
|
+
export const listOrders = defineFunction({
|
|
222
|
+
name: "listOrders",
|
|
223
|
+
kind: "query",
|
|
224
|
+
description: "List recent orders, newest first. Requires commerce:orders:read.",
|
|
225
|
+
returns: "{ orders: { id, email, status, totalCents, items, receivedAt }[] }",
|
|
226
|
+
input: {
|
|
227
|
+
limit: field.number({ optional: true, description: "Max rows (default 50)." }),
|
|
228
|
+
},
|
|
229
|
+
access: requireOrdersRead,
|
|
230
|
+
handler: async (ctx) => {
|
|
231
|
+
const records = await listRecords(ctx, orders, { limit: ctx.input.limit });
|
|
232
|
+
return {
|
|
233
|
+
orders: records.map((r) => ({
|
|
234
|
+
id: r.id,
|
|
235
|
+
email: r.data.email,
|
|
236
|
+
status: r.data.status,
|
|
237
|
+
totalCents: r.data.totalCents,
|
|
238
|
+
items: r.data.items,
|
|
239
|
+
receivedAt: r.createdAt.toISOString(),
|
|
240
|
+
})),
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** Scope-gated: update order status (no payment provider — status only). */
|
|
246
|
+
export const updateOrderStatus = defineFunction({
|
|
247
|
+
name: "updateOrderStatus",
|
|
248
|
+
kind: "mutation",
|
|
249
|
+
description:
|
|
250
|
+
"Set an order's status (pending|paid|cancelled|fulfilled). Requires commerce:orders:write.",
|
|
251
|
+
returns: "{ id: string; status: string }",
|
|
252
|
+
input: {
|
|
253
|
+
id: field.string({ description: "Order row id (uuid)." }),
|
|
254
|
+
status: field.string({ description: "pending | paid | cancelled | fulfilled" }),
|
|
255
|
+
},
|
|
256
|
+
access: requireOrdersWrite,
|
|
257
|
+
handler: async (ctx) => {
|
|
258
|
+
const status = ctx.input.status as OrderStatus;
|
|
259
|
+
if (!ORDER_STATUSES.includes(status)) {
|
|
260
|
+
throw new GraftError({
|
|
261
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
262
|
+
message: `Invalid status "${ctx.input.status}".`,
|
|
263
|
+
fix: `Use one of: ${ORDER_STATUSES.join(", ")}.`,
|
|
264
|
+
details: {
|
|
265
|
+
issues: [{ path: "status", message: `expected one of ${ORDER_STATUSES.join("|")}` }],
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const record = await updateRecord(ctx, orders, ctx.input.id, { status });
|
|
270
|
+
return { id: record.id, status: record.data.status };
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Destructive + scope-gated: permanently delete an order (human-gated).
|
|
276
|
+
* Soft-cancel = updateOrderStatus → cancelled; this is the hard-delete gate demo.
|
|
277
|
+
*/
|
|
278
|
+
export const cancelOrder = defineFunction({
|
|
279
|
+
name: "cancelOrder",
|
|
280
|
+
kind: "mutation",
|
|
281
|
+
destructive: true,
|
|
282
|
+
description:
|
|
283
|
+
"Permanently delete an order. Destructive: requires human approval (graft approve) and commerce:orders:write.",
|
|
284
|
+
returns: "{ deleted: { id: string; email: string } }",
|
|
285
|
+
input: { id: field.string({ description: "Order row id (uuid)." }) },
|
|
286
|
+
access: requireOrdersWrite,
|
|
287
|
+
handler: async (ctx) => {
|
|
288
|
+
const removed = await deleteRecord(ctx, orders, ctx.input.id);
|
|
289
|
+
return { deleted: { id: removed.id, email: removed.data.email } };
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
export const collections = { products, orders };
|
|
294
|
+
export const functions = { placeOrder, listOrders, updateOrderStatus, cancelOrder };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "commerce",
|
|
3
|
+
"type": "bundle",
|
|
4
|
+
"description": "Commerce foundation: file-authoritative product catalog + db-authoritative orders with place/list/update/cancel functions.",
|
|
5
|
+
"graftVersion": "*",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@usegraft/auth": "workspace:*",
|
|
8
|
+
"@usegraft/contracts": "workspace:*"
|
|
9
|
+
},
|
|
10
|
+
"registryDependencies": [],
|
|
11
|
+
"files": [{ "source": "graft/commerce.ts", "target": "graft/commerce.ts", "role": "module" }],
|
|
12
|
+
"llms": "graft/commerce.llms.txt"
|
|
13
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Faq — MDX block (owned code; edit freely).
|
|
3
|
+
*
|
|
4
|
+
* Use in authored MDX bodies with an items prop:
|
|
5
|
+
*
|
|
6
|
+
* <Faq items={[
|
|
7
|
+
* { question: "Is content in git?", answer: "Yes — git is authoritative." },
|
|
8
|
+
* { question: "Who is the primary operator?", answer: "An AI agent." },
|
|
9
|
+
* ]} />
|
|
10
|
+
*
|
|
11
|
+
* Or spread a frontmatter `faqs` array from a collection that uses `faqFields`.
|
|
12
|
+
*/
|
|
13
|
+
export interface FaqItem {
|
|
14
|
+
question: string;
|
|
15
|
+
answer: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FaqProps {
|
|
19
|
+
items?: FaqItem[];
|
|
20
|
+
title?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Faq({ items = [], title }: FaqProps) {
|
|
24
|
+
if (items.length === 0) return null;
|
|
25
|
+
return (
|
|
26
|
+
<section className="faq" aria-label={title ?? "Frequently asked questions"}>
|
|
27
|
+
{title ? <h2 className="faq-title">{title}</h2> : null}
|
|
28
|
+
<dl className="faq-list">
|
|
29
|
+
{items.map((item) => (
|
|
30
|
+
<div key={item.question} className="faq-item">
|
|
31
|
+
<dt className="faq-question">{item.question}</dt>
|
|
32
|
+
<dd className="faq-answer">{item.answer}</dd>
|
|
33
|
+
</div>
|
|
34
|
+
))}
|
|
35
|
+
</dl>
|
|
36
|
+
</section>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
## faq (primitive)
|
|
2
|
+
|
|
3
|
+
MDX block at `components/Faq.tsx` plus optional `graft/fields/faq.ts` field group.
|
|
4
|
+
|
|
5
|
+
In MDX body (items inline):
|
|
6
|
+
|
|
7
|
+
<Faq title="Common questions" items={[
|
|
8
|
+
{ question: "Is content in git?", answer: "Yes — git is authoritative." },
|
|
9
|
+
{ question: "Who operates Graft?", answer: "An AI agent; humans are optional." },
|
|
10
|
+
]} />
|
|
11
|
+
|
|
12
|
+
Or store FAQs in frontmatter via the field helper:
|
|
13
|
+
|
|
14
|
+
import { faqFields } from "./graft/fields/faq";
|
|
15
|
+
// fields: { title: field.string(), ...faqFields }
|
|
16
|
+
|
|
17
|
+
# frontmatter
|
|
18
|
+
faqs:
|
|
19
|
+
- question: "…"
|
|
20
|
+
answer: "…"
|
|
21
|
+
|
|
22
|
+
Then in the page component: `<Faq items={doc.data.faqs ?? []} />`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* faqFields — optional structured FAQ list for a collection (owned code).
|
|
3
|
+
*
|
|
4
|
+
* Spread into a collection: `fields: { title: field.string(), ...faqFields }`.
|
|
5
|
+
* Then in MDX: `<Faq items={/* from frontmatter faqs *\/} />` or pass
|
|
6
|
+
* `doc.data.faqs` from the page component.
|
|
7
|
+
*/
|
|
8
|
+
import { field } from "@usegraft/core";
|
|
9
|
+
|
|
10
|
+
export const faqFields = {
|
|
11
|
+
faqs: field.array({
|
|
12
|
+
optional: true,
|
|
13
|
+
description: "FAQ entries shown on the page (question + answer).",
|
|
14
|
+
of: field.object({
|
|
15
|
+
fields: {
|
|
16
|
+
question: field.string({ description: "The question." }),
|
|
17
|
+
answer: field.text({ description: "The answer (plain text or short prose)." }),
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "faq",
|
|
3
|
+
"type": "block",
|
|
4
|
+
"description": "MDX Faq block — render a list of question/answer pairs in authored content.",
|
|
5
|
+
"graftVersion": "*",
|
|
6
|
+
"dependencies": {},
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"files": [
|
|
9
|
+
{ "source": "components/Faq.tsx", "target": "components/Faq.tsx", "role": "component" },
|
|
10
|
+
{ "source": "graft/fields/faq.ts", "target": "graft/fields/faq.ts", "role": "module" },
|
|
11
|
+
{ "source": "editor/Faq.json", "target": "graft/editor/Faq.json", "role": "editor" }
|
|
12
|
+
],
|
|
13
|
+
"llms": "components/faq.llms.txt"
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## scoped-access (primitive)
|
|
2
|
+
|
|
3
|
+
`graft/scoped-access.ts` names your token scopes and exposes access rules.
|
|
4
|
+
|
|
5
|
+
- `requireModerator` gates a function so only callers whose token carries the
|
|
6
|
+
`content:moderate` scope may run it (anonymous/unscoped → 401 UNAUTHORIZED).
|
|
7
|
+
- Edit the `SCOPES` map to match what your token issuer emits, then reuse the
|
|
8
|
+
rules on any function's `access:`.
|
|
9
|
+
- Requires `@usegraft/auth` in the project (mint/verify tokens there).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scoped-access — owned access helpers (edit freely).
|
|
3
|
+
*
|
|
4
|
+
* Name the token scopes your functions check in one place, then gate a function
|
|
5
|
+
* with the matching rule. `requireScopes` (from @usegraft/auth) verifies the
|
|
6
|
+
* caller's token carries the scope; anonymous or unscoped callers are rejected
|
|
7
|
+
* with UNAUTHORIZED. Point the scope strings at whatever your token issuer
|
|
8
|
+
* actually emits.
|
|
9
|
+
*/
|
|
10
|
+
import { requireScopes } from "@usegraft/auth";
|
|
11
|
+
|
|
12
|
+
/** The scopes this project's functions check. Rename or extend to match your issuer. */
|
|
13
|
+
export const SCOPES = {
|
|
14
|
+
/** Approve/hide user-generated content (comments, reviews, …). */
|
|
15
|
+
moderate: "content:moderate",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/** Access rule: the caller's token must carry the moderation scope. */
|
|
19
|
+
export const requireModerator = requireScopes(SCOPES.moderate);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scoped-access",
|
|
3
|
+
"type": "access",
|
|
4
|
+
"description": "Name your token scopes once and gate functions with them (a requireScopes preset).",
|
|
5
|
+
"graftVersion": "*",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@usegraft/auth": "workspace:*"
|
|
8
|
+
},
|
|
9
|
+
"registryDependencies": [],
|
|
10
|
+
"files": [
|
|
11
|
+
{ "source": "graft/scoped-access.ts", "target": "graft/scoped-access.ts", "role": "module" }
|
|
12
|
+
],
|
|
13
|
+
"llms": "graft/scoped-access.llms.txt"
|
|
14
|
+
}
|