@trypattern/sdk 0.1.7 → 0.1.9

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.
@@ -8,4 +8,5 @@ export declare const userSchema: z.ZodObject<{
8
8
  }, z.core.$strip>;
9
9
  export type User = z.output<typeof userSchema>;
10
10
  export type StoredUser = Omit<User, 'isAdmin'>;
11
- export declare const USER_COOKIE = "pattern_user";
11
+ export declare const USER_COOKIE = "__Host-pattern_user";
12
+ export declare const SESSION_COOKIE = "__Host-pattern_session";
package/dist/auth/user.js CHANGED
@@ -6,4 +6,5 @@ export const userSchema = z.object({
6
6
  avatarUrl: z.string().nullable(),
7
7
  isAdmin: z.boolean().default(false)
8
8
  });
9
- export const USER_COOKIE = 'pattern_user';
9
+ export const USER_COOKIE = '__Host-pattern_user';
10
+ export const SESSION_COOKIE = '__Host-pattern_session';
package/dist/cli/app.js CHANGED
@@ -270,7 +270,7 @@ and restore sign in the same way when they need to, so this is rarely run on its
270
270
  },
271
271
  pull: {
272
272
  summary: 'Download a site from onpattern.app and install it, ready for app start',
273
- details: `app pull sundayparty
273
+ details: `app pull acmeflower
274
274
  Downloads the source of the site's latest live version into this folder when it is empty, or into
275
275
  ./<name> otherwise, writes pattern.json, installs, and runs app setup. Never needs git; when the
276
276
  Command Line Tools are installed it also makes a first commit. Signs in first if needed (see app login).
@@ -1283,7 +1283,7 @@ async function setup() {
1283
1283
  problems.push(`Node ${haveNode} is older than Node ${majorOf(pins.node)} (.nvmrc says ${pins.node})\n fix pnpm env use --global ${majorOf(pins.node)}`);
1284
1284
  }
1285
1285
  if (!isAtLeastMajor(havePnpm, pins.pnpm)) {
1286
- problems.push(`pnpm ${havePnpm || '(not found)'} is older than pnpm ${majorOf(pins.pnpm)} (package.json devEngines says ${pins.pnpm} or newer)\n fix npm install --global pnpm@latest`);
1286
+ problems.push(`pnpm ${havePnpm || '(not found)'} is older than pnpm ${majorOf(pins.pnpm)} (package.json devEngines says ${pins.pnpm} or newer)\n fix npm install --global pnpm@12`);
1287
1287
  }
1288
1288
  const versions = { node: haveNode, pnpm: havePnpm, expected: pins };
1289
1289
  if (problems.length > 0) {
@@ -5,6 +5,7 @@ import { basename, dirname, extname, join, matchesGlob, relative } from 'node:pa
5
5
  import { z } from 'zod';
6
6
  import { DASHBOARD_URL, UPLOAD, appSummary, isPrivatePath, loginPoll, loginStarted, nameProblem, publishMessage, publishSummary, setupCodeExchanged, sourcePathProblem } from "../publishWire.js";
7
7
  import { appInfo } from "../appTitle.js";
8
+ import { SESSION_COOKIE } from "../auth/user.js";
8
9
  import { PROJECT_FILE, readProjectFile } from "../vite/projectFile.js";
9
10
  const CONFIG_DIR = join(homedir(), '.config', 'pattern');
10
11
  const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
@@ -13,7 +14,6 @@ const LOGIN_MINUTES = 10;
13
14
  const POLL_MS = 2000;
14
15
  const LOGIN_WAIT_MS = 100_000;
15
16
  const PUBLISH_WAIT_MS = 300_000;
16
- const SESSION_COOKIE = 'pattern_session';
17
17
  const LAUNCHER = 'app';
18
18
  const STARTER_WORKSPACE = 'pnpm-workspace.yaml';
19
19
  const STARTER_BUILD_SETTINGS = 'allowBuilds:\n esbuild: false\n workerd: false\n';
@@ -258,7 +258,7 @@ async function ensurePnpm(io, cwd) {
258
258
  if (await hasPnpm(io, cwd))
259
259
  return true;
260
260
  io.say('Installing pnpm');
261
- const installed = await io.run('npm', ['install', '--global', 'pnpm@latest'], cwd);
261
+ const installed = await io.run('npm', ['install', '--global', 'pnpm@12'], cwd);
262
262
  return installed.code === 0 && (await hasPnpm(io, cwd));
263
263
  }
264
264
  function firstChange(title, url, folder) {
@@ -277,7 +277,7 @@ export async function pull(io, name, cwd, setupCode) {
277
277
  return {
278
278
  code: 2,
279
279
  data: { error: 'missing name' },
280
- text: '✗ Which site? app pull sundayparty'
280
+ text: '✗ Which site? app pull acmeflower'
281
281
  };
282
282
  }
283
283
  const target = folderFor(name, cwd);
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
2
2
  import type { MenuEntry } from './MenuItems.tsx';
3
3
  import type { SidebarLink } from './Sidebar.tsx';
4
4
  type AppFrameProps = {
5
+ workspace?: string;
5
6
  links: readonly SidebarLink[];
6
7
  workspaceMenu?: readonly MenuEntry[];
7
8
  isSidebarToggled: boolean;
@@ -10,5 +11,5 @@ type AppFrameProps = {
10
11
  onSearch?: () => void;
11
12
  children: ReactNode;
12
13
  };
13
- export declare function AppFrame({ links, workspaceMenu, isSidebarToggled, onToggleSidebar, onCloseSidebar, onSearch, children }: AppFrameProps): import("react").JSX.Element;
14
+ export declare function AppFrame({ workspace, links, workspaceMenu, isSidebarToggled, onToggleSidebar, onCloseSidebar, onSearch, children }: AppFrameProps): import("react").JSX.Element;
14
15
  export {};
@@ -6,7 +6,7 @@ import { FrameContext } from "./frame.js";
6
6
  import { Scrim } from "./Scrim.js";
7
7
  import { Sidebar } from "./Sidebar.js";
8
8
  import { useAnimatesOnChange } from "../hooks/useAnimatesOnChange.js";
9
- export function AppFrame({ links, workspaceMenu = [], isSidebarToggled, onToggleSidebar, onCloseSidebar, onSearch, children }) {
9
+ export function AppFrame({ workspace, links, workspaceMenu = [], isSidebarToggled, onToggleSidebar, onCloseSidebar, onSearch, children }) {
10
10
  const appSettings = useModel('appSettings');
11
11
  const account = useModel('account');
12
12
  const menu = account.user === null
@@ -17,7 +17,7 @@ export function AppFrame({ links, workspaceMenu = [], isSidebarToggled, onToggle
17
17
  { id: 'signOut', label: 'Log out', onSelect: account.signOut }
18
18
  ];
19
19
  const isSidebarAnimating = useAnimatesOnChange(isSidebarToggled, SIDEBAR_ANIMATION_MS);
20
- return (_jsx(FrameContext, { value: { onToggleSidebar }, children: _jsxs("div", { ...stylex.props(styles.frame), children: [_jsx(Sidebar, { workspace: appSettings.title, workspaceMenu: menu, links: links, isToggled: isSidebarToggled, isAnimating: isSidebarAnimating, onFollowLink: onCloseSidebar, onSearch: onSearch === undefined
20
+ return (_jsx(FrameContext, { value: { onToggleSidebar }, children: _jsxs("div", { ...stylex.props(styles.frame), children: [_jsx(Sidebar, { workspace: workspace ?? appSettings.title, workspaceMenu: menu, links: links, isToggled: isSidebarToggled, isAnimating: isSidebarAnimating, onFollowLink: onCloseSidebar, onSearch: onSearch === undefined
21
21
  ? undefined
22
22
  : () => {
23
23
  onCloseSidebar();
@@ -1,6 +1,9 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { SpaceName } from './spacing.ts';
2
3
  type AppearProps = {
4
+ isVisible?: boolean;
5
+ spaceBefore?: SpaceName;
3
6
  children: ReactNode;
4
7
  };
5
- export declare function Appear({ children }: AppearProps): import("react").JSX.Element;
8
+ export declare function Appear({ isVisible, spaceBefore, children }: AppearProps): import("react").JSX.Element;
6
9
  export {};
@@ -1,21 +1,100 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as stylex from '@stylexjs/stylex';
3
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
4
  import { breakpoints, motion, space } from "../theme/tokens.stylex.js";
4
- export function Appear({ children }) {
5
- return _jsx("div", { ...stylex.props(styles.appear), children: children });
5
+ export function Appear({ isVisible = true, spaceBefore = 'none', children }) {
6
+ const inner = useRef(null);
7
+ const measured = useRef(0);
8
+ const lastShown = useRef(children);
9
+ const [height, setHeight] = useState(0);
10
+ const [isMoving, setIsMoving] = useState(true);
11
+ const [isGone, setIsGone] = useState(!isVisible);
12
+ if (isVisible)
13
+ lastShown.current = children;
14
+ if (isVisible && isGone)
15
+ setIsGone(false);
16
+ useLayoutEffect(() => {
17
+ const node = inner.current;
18
+ if (node === null)
19
+ return undefined;
20
+ const observer = new ResizeObserver(() => {
21
+ const next = node.offsetHeight;
22
+ if (next === measured.current)
23
+ return;
24
+ measured.current = next;
25
+ setIsMoving(!prefersStill());
26
+ setHeight(next);
27
+ });
28
+ observer.observe(node);
29
+ return () => observer.disconnect();
30
+ }, []);
31
+ useEffect(() => {
32
+ if (isVisible)
33
+ return;
34
+ if (prefersStill()) {
35
+ setIsGone(true);
36
+ return;
37
+ }
38
+ setIsMoving(true);
39
+ }, [isVisible]);
40
+ function settle() {
41
+ setIsMoving(false);
42
+ if (!isVisible)
43
+ setIsGone(true);
44
+ }
45
+ return (_jsx("div", { ...stylex.props(styles.frame, styles.height(isVisible ? height : 0), (isMoving || !isVisible) && styles.clipped), onTransitionEnd: (event) => {
46
+ if (event.target === event.currentTarget)
47
+ settle();
48
+ }, onTransitionCancel: (event) => {
49
+ if (event.target === event.currentTarget)
50
+ settle();
51
+ }, children: _jsx("div", { ref: inner, ...stylex.props(styles.appear, spacesBefore[spaceBefore], !isVisible && styles.hidden), children: isGone ? null : lastShown.current }) }));
52
+ }
53
+ function prefersStill() {
54
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
6
55
  }
7
56
  const enter = stylex.keyframes({
8
57
  from: { opacity: 0, transform: `translateY(${space.xs})` }
9
58
  });
10
59
  const styles = stylex.create({
60
+ frame: {
61
+ flexShrink: 0,
62
+ transitionTimingFunction: motion.slideEasing,
63
+ transitionDuration: motion.slide,
64
+ transitionProperty: 'height'
65
+ },
66
+ height: (height) => ({
67
+ height: `${height}px`
68
+ }),
69
+ clipped: {
70
+ overflow: 'hidden'
71
+ },
11
72
  appear: {
12
73
  display: 'flex',
13
74
  flexDirection: 'column',
75
+ opacity: 1,
76
+ transitionTimingFunction: motion.easeOut,
77
+ transitionDuration: motion.fast,
78
+ transitionProperty: 'opacity',
14
79
  animationName: {
15
80
  default: enter,
16
81
  [breakpoints.reducedMotion]: 'none'
17
82
  },
18
- animationDuration: motion.base,
83
+ animationDuration: motion.slide,
19
84
  animationTimingFunction: motion.easeOut
85
+ },
86
+ hidden: {
87
+ opacity: 0
20
88
  }
21
89
  });
90
+ const spacesBefore = stylex.create({
91
+ none: { paddingTop: 0 },
92
+ xxs: { paddingTop: space.xxs },
93
+ xs: { paddingTop: space.xs },
94
+ sm: { paddingTop: space.sm },
95
+ md: { paddingTop: space.md },
96
+ lg: { paddingTop: space.lg },
97
+ xl: { paddingTop: space.xl },
98
+ xxl: { paddingTop: space.xxl },
99
+ xxxl: { paddingTop: space.xxxl }
100
+ });
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as stylex from '@stylexjs/stylex';
3
3
  import { Link as RouterLink } from 'react-router';
4
- import { colors, motion, radii } from "../theme/tokens.stylex.js";
4
+ import { borders, colors, motion, radii } from "../theme/tokens.stylex.js";
5
5
  import { focusRing } from "./controlLook.js";
6
6
  import { interact, textOf } from "./interaction.js";
7
7
  export function Link({ to, href, tone = 'brand', children }) {
8
- const className = stylex.props(styles.link, looks[tone], focusRing.visible).className;
8
+ const className = stylex.props(styles.link, looks[tone], href !== undefined && styles.leaves, focusRing.visible).className;
9
9
  if (href !== undefined) {
10
10
  return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", onClick: () => interact({
11
11
  primitive: 'Link',
@@ -21,11 +21,16 @@ export function Link({ to, href, tone = 'brand', children }) {
21
21
  }
22
22
  const styles = stylex.create({
23
23
  link: {
24
- textDecoration: 'none',
24
+ textDecorationLine: 'none',
25
25
  borderRadius: radii.xs,
26
26
  transitionTimingFunction: motion.easeOut,
27
27
  transitionDuration: motion.fast,
28
28
  transitionProperty: 'color'
29
+ },
30
+ leaves: {
31
+ textDecorationLine: 'underline',
32
+ textDecorationThickness: borders.thin,
33
+ textUnderlineOffset: '0.25em'
29
34
  }
30
35
  });
31
36
  const looks = stylex.create({
@@ -4,9 +4,10 @@ type ListRowProps = {
4
4
  to?: AppPath;
5
5
  onClick?: () => void;
6
6
  isMuted?: boolean;
7
+ isCurrent?: boolean;
7
8
  leading?: ReactNode;
8
9
  trailing?: ReactNode;
9
10
  children: ReactNode;
10
11
  };
11
- export declare function ListRow({ to, onClick, isMuted, leading, trailing, children }: ListRowProps): import("react").JSX.Element;
12
+ export declare function ListRow({ to, onClick, isMuted, isCurrent, leading, trailing, children }: ListRowProps): import("react").JSX.Element;
12
13
  export {};
@@ -5,9 +5,9 @@ import { Link as RouterLink } from 'react-router';
5
5
  import { colors, controls, radii, space } from "../theme/tokens.stylex.js";
6
6
  import { focusRing } from "./controlLook.js";
7
7
  import { interact, textOf } from "./interaction.js";
8
- export function ListRow({ to, onClick, isMuted = false, leading, trailing, children }) {
8
+ export function ListRow({ to, onClick, isMuted = false, isCurrent = false, leading, trailing, children }) {
9
9
  const content = (_jsxs(_Fragment, { children: [leading, _jsx("span", { ...stylex.props(styles.main), children: children }), trailing === undefined ? null : (_jsx("span", { ...stylex.props(styles.trailing), children: trailing }))] }));
10
- const look = stylex.props(styles.row, (to !== undefined || onClick !== undefined) && styles.interactive, isMuted && styles.muted, focusRing.visible, focusRing.inside);
10
+ const look = stylex.props(styles.row, (to !== undefined || onClick !== undefined) && styles.interactive, isMuted && styles.muted, isCurrent && styles.current, focusRing.visible, focusRing.inside);
11
11
  const report = () => {
12
12
  interact({
13
13
  primitive: 'ListRow',
@@ -17,10 +17,10 @@ export function ListRow({ to, onClick, isMuted = false, leading, trailing, child
17
17
  onClick?.();
18
18
  };
19
19
  if (to !== undefined) {
20
- return (_jsx(RouterLink, { to: to, onClick: report, ...look, children: content }));
20
+ return (_jsx(RouterLink, { to: to, onClick: report, "aria-current": isCurrent ? 'page' : undefined, ...look, children: content }));
21
21
  }
22
22
  if (onClick !== undefined) {
23
- return (_jsx(BaseButton, { onClick: report, className: look.className, children: content }));
23
+ return (_jsx(BaseButton, { onClick: report, "aria-pressed": isCurrent, className: look.className, children: content }));
24
24
  }
25
25
  return _jsx("div", { ...look, children: content });
26
26
  }
@@ -46,6 +46,9 @@ const styles = stylex.create({
46
46
  muted: {
47
47
  color: colors.tertiaryForeground
48
48
  },
49
+ current: {
50
+ backgroundColor: colors.transparentHover
51
+ },
49
52
  main: {
50
53
  display: 'flex',
51
54
  flexGrow: 1,
@@ -15,7 +15,8 @@ export function Sidebar({ workspace, workspaceMenu, links, isToggled, isAnimatin
15
15
  interact({ primitive: 'AppFrame', event: 'navigate', label: link.label });
16
16
  onFollowLink();
17
17
  };
18
- return (_jsxs("nav", { ...stylex.props(styles.sidebar, isToggled && styles.sidebarToggled, isAnimating && styles.sidebarAnimating), children: [_jsx("div", { ...stylex.props(styles.header), children: _jsx(Menu, { trigger: _jsxs(_Fragment, { children: [_jsx("span", { ...stylex.props(styles.logo), children: _jsx(Text, { variant: "caption", children: workspace.charAt(0) }) }), _jsx(Text, { variant: "label", isTruncated: true, children: workspace }), _jsx(Icon, { name: "chevronDown" })] }), items: workspaceMenu }) }), onSearch === undefined ? null : (_jsx(Button, { variant: "secondary", icon: "search", shortcut: ['mod', 'k'], isFullWidth: true, onClick: onSearch, children: "Search" })), _jsx("div", { ...stylex.props(styles.body), children: _jsx(ScrollArea, { children: _jsxs("div", { ...stylex.props(styles.group), children: [_jsx("span", { ...stylex.props(styles.groupLabel), children: _jsx(Text, { variant: "label", tone: "tertiary", children: "Workspace" }) }), links.map((item) => (_jsxs(NavLink, { onClick: () => followLink(item), to: item.to, end: item.to === '/', className: ({ isActive }) => stylex.props(styles.item, isActive && styles.itemCurrent, focusRing.visible, focusRing.inside).className ?? '', children: [_jsx(Icon, { name: item.icon }), _jsx(Text, { variant: "label", isTruncated: true, style: styles.itemLabel, children: item.label }), typeof item.count !== 'number' ? null : (_jsx(Badge, { variant: "transparent", children: item.count }))] }, item.to)))] }) }) })] }));
18
+ return (_jsxs("nav", { ...stylex.props(styles.sidebar, isToggled && styles.sidebarToggled, isAnimating && styles.sidebarAnimating), children: [_jsx("div", { ...stylex.props(styles.header), children: _jsx(Menu, { trigger: _jsxs(_Fragment, { children: [_jsx("span", { ...stylex.props(styles.logo), children: _jsx(Text, { variant: "caption", children: workspace.charAt(0) }) }), _jsx(Text, { variant: "label", isTruncated: true, children: workspace }), _jsx(Icon, { name: "chevronDown" })] }), items: workspaceMenu }) }), onSearch === undefined ? null : (_jsx(Button, { variant: "secondary", icon: "search", shortcut: ['mod', 'k'], isFullWidth: true, onClick: onSearch, children: "Search" })), _jsx("div", { ...stylex.props(styles.body), children: _jsx(ScrollArea, { children: _jsxs("div", { ...stylex.props(styles.group), children: [_jsx("span", { ...stylex.props(styles.groupLabel), children: _jsx(Text, { variant: "label", tone: "tertiary", children: "Workspace" }) }), links.map((item) => (_jsxs(NavLink, { onClick: () => followLink(item), to: item.to, end: item.to === '/' ||
19
+ links.some((other) => String(other.to).startsWith(`${String(item.to)}/`)), className: ({ isActive }) => stylex.props(styles.item, isActive && styles.itemCurrent, focusRing.visible, focusRing.inside).className ?? '', children: [_jsx(Icon, { name: item.icon }), _jsx(Text, { variant: "label", isTruncated: true, style: styles.itemLabel, children: item.label }), typeof item.count !== 'number' ? null : (_jsx(Badge, { variant: "transparent", children: item.count }))] }, item.to)))] }) }) })] }));
19
20
  }
20
21
  const styles = stylex.create({
21
22
  sidebar: {
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+ export declare const TABLES_READ = "tables:read";
3
+ export declare const ASSERTION_SECONDS = 60;
4
+ export declare const NEXT_PAGE_HEADER = "x-pattern-next";
5
+ export declare const assertionKey: z.ZodObject<{
6
+ kid: z.ZodString;
7
+ kty: z.ZodLiteral<"OKP">;
8
+ crv: z.ZodLiteral<"Ed25519">;
9
+ x: z.ZodString;
10
+ d: z.ZodString;
11
+ }, z.core.$strip>;
12
+ export type AssertionKey = z.output<typeof assertionKey>;
13
+ export declare function signTablesAssertion(key: AssertionKey, { sub, aud }: {
14
+ sub: string;
15
+ aud: string;
16
+ }, now: number): Promise<string>;
17
+ export declare function verifiedTablesAssertion(token: string, { hostname, keys, now }: {
18
+ hostname: string;
19
+ keys: Readonly<Record<string, string>>;
20
+ now: number;
21
+ }): Promise<string | undefined>;
@@ -0,0 +1,110 @@
1
+ import { z } from 'zod';
2
+ export const TABLES_READ = 'tables:read';
3
+ export const ASSERTION_SECONDS = 60;
4
+ export const NEXT_PAGE_HEADER = 'x-pattern-next';
5
+ const CLOCK_SKEW_SECONDS = 30;
6
+ const ED25519 = { name: 'Ed25519' };
7
+ export const assertionKey = z.object({
8
+ kid: z.string().min(1),
9
+ kty: z.literal('OKP'),
10
+ crv: z.literal('Ed25519'),
11
+ x: z.string(),
12
+ d: z.string()
13
+ });
14
+ const assertionHeader = z.object({
15
+ alg: z.literal('EdDSA'),
16
+ typ: z.literal('JWT'),
17
+ kid: z.string()
18
+ });
19
+ const assertionClaims = z.object({
20
+ sub: z.email(),
21
+ aud: z.string(),
22
+ scope: z.literal(TABLES_READ),
23
+ iat: z.int(),
24
+ exp: z.int()
25
+ });
26
+ const encoder = new TextEncoder();
27
+ function base64url(bytes) {
28
+ return btoa(String.fromCharCode(...bytes))
29
+ .replaceAll('+', '-')
30
+ .replaceAll('/', '_')
31
+ .replaceAll('=', '');
32
+ }
33
+ function bytesOf(text) {
34
+ try {
35
+ const binary = atob(text.replaceAll('-', '+').replaceAll('_', '/'));
36
+ return Uint8Array.from(binary, (letter) => letter.charCodeAt(0));
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ function jsonOf(text) {
43
+ const bytes = bytesOf(text);
44
+ if (bytes === undefined)
45
+ return undefined;
46
+ try {
47
+ return JSON.parse(new TextDecoder().decode(bytes));
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ function encoded(value) {
54
+ return base64url(encoder.encode(JSON.stringify(value)));
55
+ }
56
+ export async function signTablesAssertion(key, { sub, aud }, now) {
57
+ const privateKey = await crypto.subtle.importKey('jwk', { kty: key.kty, crv: key.crv, x: key.x, d: key.d }, ED25519, false, ['sign']);
58
+ const signed = `${encoded({ alg: 'EdDSA', typ: 'JWT', kid: key.kid })}.${encoded({
59
+ sub,
60
+ aud,
61
+ scope: TABLES_READ,
62
+ iat: now,
63
+ exp: now + ASSERTION_SECONDS
64
+ })}`;
65
+ const signature = await crypto.subtle.sign(ED25519, privateKey, encoder.encode(signed));
66
+ return `${signed}.${base64url(new Uint8Array(signature))}`;
67
+ }
68
+ async function isSignedBy(publicKey, signed, signature) {
69
+ const keyBytes = bytesOf(publicKey);
70
+ const signatureBytes = bytesOf(signature);
71
+ if (keyBytes === undefined || signatureBytes === undefined)
72
+ return false;
73
+ try {
74
+ const key = await crypto.subtle.importKey('raw', keyBytes, ED25519, false, [
75
+ 'verify'
76
+ ]);
77
+ return await crypto.subtle.verify(ED25519, key, signatureBytes, encoder.encode(signed));
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ export async function verifiedTablesAssertion(token, { hostname, keys, now }) {
84
+ const [header, claims, signature, ...rest] = token.split('.');
85
+ if (header === undefined ||
86
+ claims === undefined ||
87
+ signature === undefined ||
88
+ rest.length > 0) {
89
+ return undefined;
90
+ }
91
+ const parsedHeader = assertionHeader.safeParse(jsonOf(header));
92
+ if (!parsedHeader.success)
93
+ return undefined;
94
+ const { kid } = parsedHeader.data;
95
+ const publicKey = Object.hasOwn(keys, kid) ? keys[kid] : undefined;
96
+ if (publicKey === undefined)
97
+ return undefined;
98
+ if (!(await isSignedBy(publicKey, `${header}.${claims}`, signature))) {
99
+ return undefined;
100
+ }
101
+ const parsed = assertionClaims.safeParse(jsonOf(claims));
102
+ if (!parsed.success)
103
+ return undefined;
104
+ const { sub, aud, iat, exp } = parsed.data;
105
+ const isFresh = exp > iat &&
106
+ exp - iat <= ASSERTION_SECONDS &&
107
+ iat <= now + CLOCK_SKEW_SECONDS &&
108
+ now < exp;
109
+ return aud === hostname && isFresh ? sub.toLowerCase() : undefined;
110
+ }