@oneteam-app/apps-cli 0.1.0-alpha.5 → 0.1.0-alpha.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/CHANGELOG.md +23 -0
- package/README.md +46 -2
- package/dist/index.js +1943 -54
- package/package.json +8 -6
- package/templates/hosted/.dev.vars.example +11 -0
- package/templates/hosted/README.md +119 -0
- package/templates/hosted/_gitignore +11 -0
- package/templates/hosted/index.html +16 -0
- package/templates/hosted/migrations/0001_init.sql +16 -0
- package/templates/hosted/oneteam.app.json +17 -0
- package/templates/hosted/package.json +34 -0
- package/templates/hosted/src/client/App.tsx +276 -0
- package/templates/hosted/src/client/main.tsx +10 -0
- package/templates/hosted/src/client/styles.css +6 -0
- package/templates/hosted/src/shared.ts +22 -0
- package/templates/hosted/src/worker/dev.ts +15 -0
- package/templates/hosted/src/worker/env.d.ts +10 -0
- package/templates/hosted/src/worker/index.test.ts +113 -0
- package/templates/hosted/src/worker/index.ts +121 -0
- package/templates/hosted/src/worker/items.ts +85 -0
- package/templates/hosted/src/worker/jobs.ts +22 -0
- package/templates/hosted/tsconfig.json +16 -0
- package/templates/hosted/tsconfig.worker.json +16 -0
- package/templates/hosted/vite.config.ts +15 -0
- package/templates/hosted/vitest.config.ts +16 -0
- package/templates/hosted/wrangler.jsonc +18 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { applyD1Migrations, env } from 'cloudflare:test';
|
|
2
|
+
import { withFakeContext, type FakeContextOptions } from '@oneteam-app/apps/testing';
|
|
3
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
4
|
+
import type { Item, ItemsResponse } from '../shared';
|
|
5
|
+
import { app, type Env } from './index';
|
|
6
|
+
|
|
7
|
+
// Who is asking. Permissions are Oneteam permissions, held per community (store).
|
|
8
|
+
const sam: FakeContextOptions = { userId: '123', firstName: 'Sam', communities: [{ id: '12', name: 'Store Amsterdam' }] };
|
|
9
|
+
const noor: FakeContextOptions = {
|
|
10
|
+
userId: '456',
|
|
11
|
+
firstName: 'Noor',
|
|
12
|
+
communities: [{ id: '12', name: 'Store Amsterdam', permissions: ['network::users.update'] }, { id: '13', name: 'Store Rotterdam' }],
|
|
13
|
+
};
|
|
14
|
+
const lisa: FakeContextOptions = { userId: '999', firstName: 'Lisa', communities: [{ id: '13', name: 'Store Rotterdam' }] };
|
|
15
|
+
|
|
16
|
+
const bindings = { DB: env.DB, ASSETS: { fetch: async () => new Response('front end') } } as unknown as Env;
|
|
17
|
+
|
|
18
|
+
function call(path: string, as: FakeContextOptions, body?: unknown) {
|
|
19
|
+
return app.request(
|
|
20
|
+
withFakeContext(path, as, body === undefined ? undefined : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
|
|
21
|
+
undefined,
|
|
22
|
+
bindings,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function logItem(as: FakeContextOptions, title: string, communityId?: string): Promise<Item> {
|
|
27
|
+
const response = await call('/api/items', as, { title, location: 'Fitting rooms', communityId });
|
|
28
|
+
|
|
29
|
+
expect(response.status).toBe(201);
|
|
30
|
+
|
|
31
|
+
return response.json();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeAll(() => applyD1Migrations(env.DB, env.TEST_MIGRATIONS));
|
|
35
|
+
|
|
36
|
+
describe('who can see what', () => {
|
|
37
|
+
it('refuses a request that did not come through the sign-on gateway', async () => {
|
|
38
|
+
const response = await app.request('http://app.test/api/items', undefined, bindings);
|
|
39
|
+
|
|
40
|
+
expect(response.status).toBe(401);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("only lists items of the user's own stores", async () => {
|
|
44
|
+
await logItem(sam, 'Blue umbrella');
|
|
45
|
+
await logItem(lisa, 'Car keys');
|
|
46
|
+
|
|
47
|
+
const forSam: ItemsResponse = await (await call('/api/items', sam)).json();
|
|
48
|
+
const forNoor: ItemsResponse = await (await call('/api/items', noor)).json();
|
|
49
|
+
|
|
50
|
+
expect(forSam.items.map((item) => item.title)).toEqual(['Blue umbrella']);
|
|
51
|
+
expect(forNoor.items.map((item) => item.title).sort()).toEqual(['Blue umbrella', 'Car keys']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not let someone log an item for a store they are not in', async () => {
|
|
55
|
+
const response = await call('/api/items', sam, { title: 'Scarf', communityId: '13' });
|
|
56
|
+
|
|
57
|
+
expect(response.status).toBe(403);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('marking an item as returned', () => {
|
|
62
|
+
it('is refused without the permission in that store', async () => {
|
|
63
|
+
const item = await logItem(sam, 'Wallet');
|
|
64
|
+
const response = await call(`/api/items/${item.id}/return`, sam, { returnedTo: 'A customer' });
|
|
65
|
+
|
|
66
|
+
expect(response.status).toBe(403);
|
|
67
|
+
expect(await response.json()).toMatchObject({ error: 'forbidden', permission: 'network::users.update', community_id: '12' });
|
|
68
|
+
expect(item.canReturn).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('is allowed for a manager of that store', async () => {
|
|
72
|
+
const item = await logItem(sam, 'Phone charger');
|
|
73
|
+
const response = await call(`/api/items/${item.id}/return`, noor, { returnedTo: 'Mr. de Boer' });
|
|
74
|
+
const returned: Item = await response.json();
|
|
75
|
+
|
|
76
|
+
expect(response.status).toBe(200);
|
|
77
|
+
expect(returned.returnedTo).toBe('Mr. de Boer');
|
|
78
|
+
expect(returned.returnedAt).not.toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('is refused for a manager of another store: the permission counts per store', async () => {
|
|
82
|
+
// Noor manages Amsterdam (12) and is only a member of Rotterdam (13).
|
|
83
|
+
const item = await logItem(lisa, 'Sunglasses');
|
|
84
|
+
const response = await call(`/api/items/${item.id}/return`, noor, { returnedTo: 'Someone' });
|
|
85
|
+
|
|
86
|
+
expect(response.status).toBe(403);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('answers "not found" for an item of a store the user is not in', async () => {
|
|
90
|
+
const item = await logItem(lisa, 'Gloves');
|
|
91
|
+
const response = await call(`/api/items/${item.id}/return`, sam, { returnedTo: 'Someone' });
|
|
92
|
+
|
|
93
|
+
expect(response.status).toBe(404);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('scheduled jobs', () => {
|
|
98
|
+
it('answers the platform\'s call for a job this app does not have with 404, and never reaches the app', async () => {
|
|
99
|
+
const { default: worker } = await import('./index');
|
|
100
|
+
const { fakeJobRequest } = await import('@oneteam-app/apps/testing');
|
|
101
|
+
const response = await worker.fetch(fakeJobRequest('send-reminders'), bindings, undefined as never);
|
|
102
|
+
|
|
103
|
+
expect(response.status).toBe(404);
|
|
104
|
+
expect(((await response.json()) as { error: string }).error).toContain('no handler for the job "send-reminders"');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('does not let a signed-in person call the job path', async () => {
|
|
108
|
+
const { default: worker } = await import('./index');
|
|
109
|
+
const response = await worker.fetch(withFakeContext('/__oneteam/jobs/send-reminders', noor, { method: 'POST' }), bindings, undefined as never);
|
|
110
|
+
|
|
111
|
+
expect(response.status).toBe(404);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { assertCan, can, errorResponse, oneteam, toClientSession, withJobs, type HostedSession, type OneteamVariables } from '@oneteam-app/apps/hosted';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { RETURN_PERMISSION, type Item, type ItemsResponse } from '../shared';
|
|
4
|
+
import { addItem, findItem, listItems, markReturned } from './items';
|
|
5
|
+
import { jobs } from './jobs';
|
|
6
|
+
|
|
7
|
+
export interface Env {
|
|
8
|
+
/** This app's own database. The only way to reach it: there are no credentials */
|
|
9
|
+
DB: D1Database;
|
|
10
|
+
/** The built front end */
|
|
11
|
+
ASSETS: Fetcher;
|
|
12
|
+
// A secret (an API key for another service) is a name here and under
|
|
13
|
+
// "secrets" in oneteam.app.json, never a value in the code:
|
|
14
|
+
//
|
|
15
|
+
// SUPABASE_SERVICE_KEY: string;
|
|
16
|
+
//
|
|
17
|
+
// Read it with `secret(c.env, 'SUPABASE_SERVICE_KEY')` from
|
|
18
|
+
// '@oneteam-app/apps/hosted', only in this Worker. See "Secrets" in the README.
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The communities (stores) the signed-in user belongs to: the scope of everything they can see. */
|
|
22
|
+
function communitiesOf(session: HostedSession) {
|
|
23
|
+
return session.user.communities ?? [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function withAccess(session: HostedSession, item: Omit<Item, 'canReturn'>): Item {
|
|
27
|
+
return { ...item, canReturn: !item.returnedAt && can(session, RETURN_PERMISSION, { communityId: item.communityId }) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function text(value: unknown, max: number): string {
|
|
31
|
+
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const api = new Hono<{ Bindings: Env; Variables: OneteamVariables }>();
|
|
35
|
+
|
|
36
|
+
// Every route below has a signed-in user. Requests that did not come through
|
|
37
|
+
// the sign-on gateway get a 401 here and never reach a route.
|
|
38
|
+
api.use(oneteam());
|
|
39
|
+
|
|
40
|
+
api.get('/me', (c) => c.json(toClientSession(c.get('session'))));
|
|
41
|
+
|
|
42
|
+
api.get('/items', async (c) => {
|
|
43
|
+
const session = c.get('session');
|
|
44
|
+
const communities = communitiesOf(session);
|
|
45
|
+
const items = await listItems(c.env.DB, communities.map((community) => community.id));
|
|
46
|
+
const body: ItemsResponse = {
|
|
47
|
+
items: items.map((item) => withAccess(session, item)),
|
|
48
|
+
communities: communities.map(({ id, name }) => ({ id, name })),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return c.json(body);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
api.post('/items', async (c) => {
|
|
55
|
+
const session = c.get('session');
|
|
56
|
+
const input = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
|
|
57
|
+
const communities = communitiesOf(session);
|
|
58
|
+
const title = text(input.title, 120);
|
|
59
|
+
// Never trust a community id from the browser: it has to be one of the user's own.
|
|
60
|
+
const community = communities.find((item) => item.id === input.communityId) ?? communities.find((item) => item.isPrimary) ?? communities[0];
|
|
61
|
+
|
|
62
|
+
if (!title) return c.json({ error: 'Describe what was found' }, 400);
|
|
63
|
+
if (!community || (input.communityId !== undefined && community.id !== input.communityId)) {
|
|
64
|
+
return c.json({ error: 'You can only log items for your own store' }, 403);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const item = await addItem(c.env.DB, {
|
|
68
|
+
communityId: community.id,
|
|
69
|
+
title,
|
|
70
|
+
location: text(input.location, 120),
|
|
71
|
+
userId: session.user.id,
|
|
72
|
+
userName: session.user.name,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return c.json(withAccess(session, item), 201);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
api.post('/items/:id/return', async (c) => {
|
|
79
|
+
const session = c.get('session');
|
|
80
|
+
const input = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
|
|
81
|
+
const item = await findItem(c.env.DB, Number(c.req.param('id')), communitiesOf(session).map((community) => community.id));
|
|
82
|
+
|
|
83
|
+
// An item of another store is not found, rather than forbidden: its existence is not theirs to know.
|
|
84
|
+
if (!item) return c.json({ error: 'Item not found' }, 404);
|
|
85
|
+
|
|
86
|
+
// Only people who manage colleagues in the item's store hand items back.
|
|
87
|
+
assertCan(session, RETURN_PERMISSION, { communityId: item.communityId });
|
|
88
|
+
|
|
89
|
+
const returnedTo = text(input.returnedTo, 120);
|
|
90
|
+
|
|
91
|
+
if (!returnedTo) return c.json({ error: 'Say who the item was returned to' }, 400);
|
|
92
|
+
|
|
93
|
+
const updated = await markReturned(c.env.DB, item.id, item.communityId, returnedTo, session.user.id);
|
|
94
|
+
|
|
95
|
+
if (!updated) return c.json({ error: 'This item was already returned' }, 409);
|
|
96
|
+
|
|
97
|
+
return c.json(withAccess(session, updated));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
api.all('*', (c) => c.json({ error: 'Not found' }, 404));
|
|
101
|
+
|
|
102
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
103
|
+
|
|
104
|
+
app.route('/api', api);
|
|
105
|
+
// Everything else is the front end.
|
|
106
|
+
app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw));
|
|
107
|
+
app.onError((error, c) => {
|
|
108
|
+
// 401 without a session, 403 for a failed assertCan.
|
|
109
|
+
const known = errorResponse(error);
|
|
110
|
+
|
|
111
|
+
if (known) return known;
|
|
112
|
+
|
|
113
|
+
console.error(error);
|
|
114
|
+
|
|
115
|
+
return c.json({ error: 'Something went wrong' }, 500);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export { app };
|
|
119
|
+
// The platform's calls for a scheduled job go to its handler in jobs.ts;
|
|
120
|
+
// everything else is the app.
|
|
121
|
+
export default withJobs<Env, typeof app>(app, jobs);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Item } from '../shared';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* All database access for items. Two rules, everywhere:
|
|
5
|
+
* - values are bound with `?`, never put into the SQL text
|
|
6
|
+
* - every query is limited to the communities passed in, which come from the session
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface ItemRow {
|
|
10
|
+
id: number;
|
|
11
|
+
community_id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
location: string;
|
|
14
|
+
found_by_name: string;
|
|
15
|
+
created_at: string;
|
|
16
|
+
returned_at: string | null;
|
|
17
|
+
returned_to: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const COLUMNS = 'id, community_id, title, location, found_by_name, created_at, returned_at, returned_to';
|
|
21
|
+
|
|
22
|
+
function toItem(row: ItemRow): Omit<Item, 'canReturn'> {
|
|
23
|
+
return {
|
|
24
|
+
id: row.id,
|
|
25
|
+
communityId: row.community_id,
|
|
26
|
+
title: row.title,
|
|
27
|
+
location: row.location,
|
|
28
|
+
foundByName: row.found_by_name,
|
|
29
|
+
createdAt: row.created_at,
|
|
30
|
+
returnedAt: row.returned_at,
|
|
31
|
+
returnedTo: row.returned_to,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `?, ?, ?` for an IN list: the ids themselves are bound, not interpolated. */
|
|
36
|
+
function placeholders(values: unknown[]): string {
|
|
37
|
+
return values.map(() => '?').join(', ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listItems(db: D1Database, communityIds: string[]) {
|
|
41
|
+
if (communityIds.length === 0) return [];
|
|
42
|
+
|
|
43
|
+
const { results } = await db
|
|
44
|
+
.prepare(`SELECT ${COLUMNS} FROM items WHERE community_id IN (${placeholders(communityIds)}) ORDER BY returned_at IS NOT NULL, created_at DESC LIMIT 200`)
|
|
45
|
+
.bind(...communityIds)
|
|
46
|
+
.all<ItemRow>();
|
|
47
|
+
|
|
48
|
+
return results.map(toItem);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An item the user may see, or null: an item of another community does not exist for them. */
|
|
52
|
+
export async function findItem(db: D1Database, id: number, communityIds: string[]) {
|
|
53
|
+
if (communityIds.length === 0) return null;
|
|
54
|
+
|
|
55
|
+
const row = await db
|
|
56
|
+
.prepare(`SELECT ${COLUMNS} FROM items WHERE id = ? AND community_id IN (${placeholders(communityIds)})`)
|
|
57
|
+
.bind(id, ...communityIds)
|
|
58
|
+
.first<ItemRow>();
|
|
59
|
+
|
|
60
|
+
return row ? toItem(row) : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function addItem(
|
|
64
|
+
db: D1Database,
|
|
65
|
+
item: { communityId: string; title: string; location: string; userId: string; userName: string },
|
|
66
|
+
) {
|
|
67
|
+
const row = await db
|
|
68
|
+
.prepare(`INSERT INTO items (community_id, title, location, found_by_user_id, found_by_name) VALUES (?, ?, ?, ?, ?) RETURNING ${COLUMNS}`)
|
|
69
|
+
.bind(item.communityId, item.title, item.location, item.userId, item.userName)
|
|
70
|
+
.first<ItemRow>();
|
|
71
|
+
|
|
72
|
+
return toItem(row!);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function markReturned(db: D1Database, id: number, communityId: string, returnedTo: string, userId: string) {
|
|
76
|
+
const row = await db
|
|
77
|
+
.prepare(
|
|
78
|
+
`UPDATE items SET returned_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), returned_to = ?, returned_by_user_id = ?
|
|
79
|
+
WHERE id = ? AND community_id = ? AND returned_at IS NULL RETURNING ${COLUMNS}`,
|
|
80
|
+
)
|
|
81
|
+
.bind(returnedTo, userId, id, communityId)
|
|
82
|
+
.first<ItemRow>();
|
|
83
|
+
|
|
84
|
+
return row ? toItem(row) : null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineJobs } from '@oneteam-app/apps/hosted';
|
|
2
|
+
import type { Env } from './index';
|
|
3
|
+
|
|
4
|
+
// Jobs the platform runs on a schedule. Each name here is also listed under
|
|
5
|
+
// "schedules" in oneteam.app.json, and the other way round: `oneteam-apps
|
|
6
|
+
// deploy` checks both before it uploads anything.
|
|
7
|
+
//
|
|
8
|
+
// "schedules": [{ "name": "send-reminders", "every": "day", "at": "07:00" }],
|
|
9
|
+
// "sends": ["notifications"]
|
|
10
|
+
//
|
|
11
|
+
// 'send-reminders': async (job, env) => {
|
|
12
|
+
// const waiting = await itemsWaitingLongerThan(env.DB, 7);
|
|
13
|
+
//
|
|
14
|
+
// for (const item of waiting) {
|
|
15
|
+
// await notify(job, { userIds: [item.foundBy], title: 'Still waiting', body: `${item.title} has been here for a week.` });
|
|
16
|
+
// }
|
|
17
|
+
// },
|
|
18
|
+
//
|
|
19
|
+
// Nobody is signed in while a job runs: there is no session. A job has 30
|
|
20
|
+
// seconds, a run that is missed is not caught up, and one can be started again
|
|
21
|
+
// by hand, so keep it short and safe to run twice.
|
|
22
|
+
export const jobs = defineJobs<Env>({});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"types": ["vite/client"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/client", "src/shared.ts", "vite.config.ts"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/worker", "src/shared.ts", "vitest.config.ts", "oneteam.app.json"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cloudflare } from '@cloudflare/vite-plugin';
|
|
2
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
3
|
+
import react from '@vitejs/plugin-react';
|
|
4
|
+
import { defineConfig } from 'vite';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
// Relative asset URLs: the app is served from /<organisation>/<app>/ and
|
|
8
|
+
// from /<organisation>/<app>--preview/, never from a path known at build time.
|
|
9
|
+
base: './',
|
|
10
|
+
plugins: [react(), tailwindcss(), cloudflare()],
|
|
11
|
+
// One copy of React, also when a package that uses it is linked from elsewhere.
|
|
12
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
13
|
+
// Not 5173, which another project usually has.
|
|
14
|
+
server: { port: 5310, strictPort: true },
|
|
15
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin';
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
|
|
4
|
+
// Tests run in the Workers runtime with a real, empty D1 database per test file.
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [
|
|
7
|
+
cloudflareTest(async () => ({
|
|
8
|
+
miniflare: {
|
|
9
|
+
compatibilityDate: '2026-09-01',
|
|
10
|
+
d1Databases: ['DB'],
|
|
11
|
+
bindings: { TEST_MIGRATIONS: await readD1Migrations('./migrations') },
|
|
12
|
+
},
|
|
13
|
+
})),
|
|
14
|
+
],
|
|
15
|
+
test: { include: ['src/worker/**/*.test.ts'] },
|
|
16
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Local development only (`pnpm dev`). The platform runs the app from the
|
|
3
|
+
// bundle `oneteam-apps deploy` uploads and provides the same two bindings:
|
|
4
|
+
// DB (this app's own database) and ASSETS (the built front end).
|
|
5
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
6
|
+
"name": "lost-and-found",
|
|
7
|
+
// The dev entry adds a stand-in sign-on. The deployed Worker is src/worker/index.ts.
|
|
8
|
+
"main": "src/worker/dev.ts",
|
|
9
|
+
"compatibility_date": "2026-09-01",
|
|
10
|
+
"assets": {
|
|
11
|
+
"binding": "ASSETS",
|
|
12
|
+
"not_found_handling": "single-page-application",
|
|
13
|
+
"run_worker_first": true
|
|
14
|
+
},
|
|
15
|
+
"d1_databases": [
|
|
16
|
+
{ "binding": "DB", "database_name": "lost-and-found", "database_id": "local", "migrations_dir": "migrations" }
|
|
17
|
+
]
|
|
18
|
+
}
|