ReviewOS

also looking at this

stacks/wildloop

feat: native trail maps and complete native mobile screens

#1017
Draft chris wants to merge codex/trail-experience into main
60 files +2505 -823

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

resources/functions/native-api.tsmodified+39-5
Changes to resources/functions/native-api.ts
@@ -73,8 +73,12 @@ export interface NativeApi {
7373 send: (path: string, method?: string, body?: unknown) => Promise<NativeResponse>
7474 /** Native FormData carries on-device files through Craft's multipart transport. */
7575 upload: (path: string, form: FormData) => Promise<NativeResponse>
76 /** Keep a protected operation with the screen's loaded account; never retarget it. */
77 bindSession: () => Promise<NativeApi>
7678}
7779
80interface NativeSessionBinding { account: string, bearer: string }
81
7882/**
7983 * A token reader and sender bound to one screen.
8084 *
@@ -89,6 +93,15 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho
8993 let reading: Promise<string> | null = null
9094 let session = ''
9195 const sessionKey = (): string => host.snapshots ? JSON.stringify(host.snapshots.get('wildloop-session')) ?? 'null' : ''
96 const accountChanged = (): Error => new Error('Your account changed. Please try again.')
97 const assertSession = async (binding: NativeSessionBinding): Promise<string> => {
98 if (binding.account !== sessionKey()) throw accountChanged()
99 const current = await host.secureStorage.get('auth_token')
100 // The keychain can rotate before the shared account snapshot is published,
101 // and cold starts can have a bearer without any snapshot yet.
102 if (binding.account !== sessionKey() || current !== binding.bearer) throw accountChanged()
103 return binding.bearer
104 }
92105 const readToken = async (): Promise<string> => {
93106 const current = sessionKey()
94107 if (!host.snapshots || session !== current) {
@@ -120,11 +133,11 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho
120133 return { 'X-CSRF-Token': token, Cookie: `X-CSRF-Token=${token}` }
121134 }
122135
123 const request = async (path: string, method: string, body: string | FormData | undefined, json: boolean): Promise<NativeResponse> => {
124 const account = sessionKey()
125 const bearer = await readToken()
136 const request = async (path: string, method: string, body: string | FormData | undefined, json: boolean, binding?: NativeSessionBinding): Promise<NativeResponse> => {
137 const account = binding?.account ?? sessionKey()
138 const bearer = binding ? await assertSession(binding) : await readToken()
126139 const csrf = !bearer && !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()) ? await guestMutationHeaders() : {}
127 if (account !== sessionKey()) throw new Error('Your account changed. Please try again.')
140 if (account !== sessionKey()) throw accountChanged()
128141 const response = await fetch(`${origin}${path}`, {
129142 method,
130143 headers: { ...nativeHeaders(bearer, json), ...csrf },
@@ -133,14 +146,35 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho
133146 // A body that is not JSON is not an exception — an error page is text, and
134147 // the status is what the screen reads anyway.
135148 const payload = await response.json().catch(() => null)
136 if (account !== sessionKey()) throw new Error('Your account changed. Please try again.')
149 if (binding) await assertSession(binding)
150 else if (account !== sessionKey()) throw accountChanged()
137151 return { ok: response.ok, status: response.status, body: payload }
138152 }
139153
154 const bindSession = async (): Promise<NativeApi> => {
155 const account = sessionKey()
156 if (reading && session !== account) throw accountChanged()
157 // Anchor an already-loaded screen to its last bearer, even on a host
158 // without snapshots. Reading a fresh bearer here would retarget its draft.
159 const bearer = await (reading ?? readToken())
160 if (account !== sessionKey()) throw accountChanged()
161 if (!bearer) throw new Error('Sign in before saving changes.')
162 const binding = { account, bearer }
163 await assertSession(binding)
164 const bound: NativeApi = {
165 readToken: () => assertSession(binding),
166 send: (path, method = 'GET', body) => request(path, method, body === undefined ? undefined : JSON.stringify(body), body !== undefined, binding),
167 upload: (path, form) => request(path, 'POST', form, false, binding),
168 bindSession: async () => { await assertSession(binding); return bound },
169 }
170 return bound
171 }
172
140173 return {
141174 readToken,
142175 send: (path, method = 'GET', body) => request(path, method, body === undefined ? undefined : JSON.stringify(body), body !== undefined),
143176 upload: (path, form) => request(path, 'POST', form, false),
177 bindSession,
144178 }
145179}
146180