@arcforgelabs/flow 2026.10.49 → 2026.10.51
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/SECURITY.md +9 -4
- package/VISION.md +18 -9
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +109 -0
- package/server/api/invoicing.js +7 -3
- package/server/app.js +13 -0
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +144 -0
- package/server/core/captures.js +397 -0
- package/server/core/invoice-rules.js +17 -3
- package/server/core/job-board.js +1 -1
- package/server/core/reader.js +333 -0
- package/server/core/sending.js +27 -9
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +47 -0
- package/server/index.js +47 -1
- package/server/integrations/xero/connections.js +2 -0
- package/server/integrations/xero/index.js +1 -0
- package/server/integrations/xero/oauth.js +10 -3
- package/server/integrations/xero/routes.js +58 -40
- package/server/integrations/xero/runtime.js +7 -4
- package/server/integrations/xero/settings.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +58 -3
- package/server/ui/flow.css +6 -2
- package/server/ui/invoice-rules.js +1 -1
- package/server/ui/invoicing.js +20 -9
- package/server/ui/job-board.js +3 -1
- package/server/ui/jobs.js +1 -1
- package/server/ui/mobile.js +1 -1
package/server/db/schema.js
CHANGED
|
@@ -858,3 +858,50 @@ export const jobFieldEvents = pgTable('job_field_events', {
|
|
|
858
858
|
foreignKey({ name: 'job_field_events_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
|
|
859
859
|
index('job_field_events_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef, t.createdAt),
|
|
860
860
|
]);
|
|
861
|
+
|
|
862
|
+
// A photo of a handwritten lab form (server/core/captures.js). The bytes live
|
|
863
|
+
// in the object storage adapter; this row keeps the key, size, type and
|
|
864
|
+
// checksum. `read` is what the vision reader returned (server/core/reader.js):
|
|
865
|
+
// the fields, the model, the tokens and the cost. A new row is `reading`
|
|
866
|
+
// while that runs, or `problem` when the reader is off. A person may type
|
|
867
|
+
// the proposal either way. `done` means someone applied or discarded it.
|
|
868
|
+
// The photo bytes are deleted then, and a row with no decision is deleted
|
|
869
|
+
// after 7 days.
|
|
870
|
+
export const CAPTURE_STATES = ['reading', 'ready', 'done', 'problem'];
|
|
871
|
+
export const CAPTURE_SOURCES = ['phone', 'desk', 'api'];
|
|
872
|
+
export const CAPTURE_DECISIONS = ['applied', 'discarded'];
|
|
873
|
+
export const captures = pgTable('captures', {
|
|
874
|
+
id: id(),
|
|
875
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
876
|
+
state: text('state').notNull(),
|
|
877
|
+
source: text('source').notNull(),
|
|
878
|
+
fileKey: text('file_key').notNull(),
|
|
879
|
+
fileType: text('file_type').notNull(),
|
|
880
|
+
fileSize: integer('file_size').notNull(),
|
|
881
|
+
fileSha256: text('file_sha256').notNull(),
|
|
882
|
+
idempotencyKey: text('idempotency_key').notNull(),
|
|
883
|
+
// Fields, model, token counts and cost in micros. Null until a read finishes.
|
|
884
|
+
read: jsonb('read'),
|
|
885
|
+
match: jsonb('match'),
|
|
886
|
+
proposal: jsonb('proposal').notNull().default(sql`'{"action":null,"jobId":null,"changes":[]}'::jsonb`),
|
|
887
|
+
problem: text('problem'),
|
|
888
|
+
decision: text('decision'),
|
|
889
|
+
decidedBy: uuid('decided_by').references(() => people.id, { onDelete: 'set null' }),
|
|
890
|
+
decidedAt: timestamp('decided_at', { withTimezone: true }),
|
|
891
|
+
photoDeletedAt: timestamp('photo_deleted_at', { withTimezone: true }),
|
|
892
|
+
createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
|
|
893
|
+
createdAt: createdAt(),
|
|
894
|
+
updatedAt: updatedAt(),
|
|
895
|
+
}, (t) => [
|
|
896
|
+
unique('captures_org_id').on(t.organisationId, t.id),
|
|
897
|
+
unique('captures_org_idempotency').on(t.organisationId, t.idempotencyKey),
|
|
898
|
+
unique('captures_file_key').on(t.fileKey),
|
|
899
|
+
index('captures_photo_open').on(t.createdAt).where(sql`photo_deleted_at is null`),
|
|
900
|
+
check('captures_state_valid', inList('state', CAPTURE_STATES)),
|
|
901
|
+
check('captures_source_valid', inList('source', CAPTURE_SOURCES)),
|
|
902
|
+
check('captures_decision_valid', sql`${t.decision} is null or ${t.decision} in ('applied', 'discarded')`),
|
|
903
|
+
check('captures_done_has_decision', sql`(${t.state} = 'done') = (${t.decision} is not null)`),
|
|
904
|
+
check('captures_file_size_valid', sql`${t.fileSize} >= 0`),
|
|
905
|
+
check('captures_sha256_valid', sql`${t.fileSha256} ~ '^[0-9a-f]{64}$'`),
|
|
906
|
+
check('captures_problem_valid', sql`${t.problem} is null or length(${t.problem}) between 1 and 200`),
|
|
907
|
+
]);
|
package/server/index.js
CHANGED
|
@@ -9,7 +9,12 @@ import { migrateDatabase, openDatabase } from './db/index.js';
|
|
|
9
9
|
import {
|
|
10
10
|
importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
|
|
11
11
|
} from './import/at-start.js';
|
|
12
|
+
import { createAiPort, resolveReader } from './core/ai.js';
|
|
13
|
+
import { createBedrockClient } from './core/ai-bedrock.js';
|
|
14
|
+
import { createCaptureRetentionWorker } from './core/captures.js';
|
|
12
15
|
import { createMonitoredLog } from './core/monitoring.js';
|
|
16
|
+
import { createCaptureReader } from './core/reader.js';
|
|
17
|
+
import { resolveObjectStorage } from './core/storage.js';
|
|
13
18
|
import { createTracing, traceQueries } from './core/tracing.js';
|
|
14
19
|
import { createPipelineWorker } from './core/pipeline.js';
|
|
15
20
|
import { createReconcileWorker } from './core/reconcile.js';
|
|
@@ -117,6 +122,43 @@ if (jobsOn) createReconcileWorker({ db: database.db, integrations, log }).start(
|
|
|
117
122
|
// with Send all ready, to the accounting integration that is on.
|
|
118
123
|
if (jobsOn) createSendWorker({ db: database.db, integrations, log }).start();
|
|
119
124
|
|
|
125
|
+
// Capture photos (server/core/storage.js). A bad storage setting stops boot.
|
|
126
|
+
// On Replit, and where the local folder cannot be created, the default is
|
|
127
|
+
// off. The retention pass
|
|
128
|
+
// only deletes Flow's own files, so it still runs where background jobs are
|
|
129
|
+
// off: a second copy deleting the same key does nothing.
|
|
130
|
+
let storage = null;
|
|
131
|
+
try {
|
|
132
|
+
const resolved = await resolveObjectStorage({ env: process.env, secrets });
|
|
133
|
+
storage = resolved.storage;
|
|
134
|
+
if (storage) console.log(`[storage] ${resolved.mode}`);
|
|
135
|
+
else console.log(`[storage] captures off (${resolved.reason})`);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error(`[storage] ${error.message}`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
if (storage) createCaptureRetentionWorker({ db: database.db, storage, log }).start();
|
|
141
|
+
|
|
142
|
+
// The vision reader (server/core/reader.js). A bad region or a model id that
|
|
143
|
+
// is not an Australia profile stops boot. Off, or on without keys, stores
|
|
144
|
+
// the photo and leaves the capture for a person to type.
|
|
145
|
+
let reader = null;
|
|
146
|
+
try {
|
|
147
|
+
const resolved = resolveReader({ env: process.env });
|
|
148
|
+
if (!resolved.on) console.log(`[reader] off (${resolved.reason})`);
|
|
149
|
+
else if (!storage) console.log('[reader] captures off (no file store)');
|
|
150
|
+
else {
|
|
151
|
+
const port = createAiPort(createBedrockClient(resolved));
|
|
152
|
+
reader = createCaptureReader({ db: database.db, storage, port, settings: resolved, log, jobsOn });
|
|
153
|
+
if (jobsOn) reader.worker.start();
|
|
154
|
+
else console.log('[reader] background jobs off: each capture is read in the upload');
|
|
155
|
+
console.log(`[reader] on (${resolved.region})`);
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error(`[reader] ${error.message}`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
120
162
|
let auth = null;
|
|
121
163
|
if (settings.mode === 'clerk') {
|
|
122
164
|
const { keys } = settings;
|
|
@@ -158,7 +200,11 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
|
|
|
158
200
|
|
|
159
201
|
const updates = createUpdateChecker();
|
|
160
202
|
|
|
161
|
-
createApp({
|
|
203
|
+
createApp({
|
|
204
|
+
database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
|
|
205
|
+
publicOrigin: process.env.FLOW_PUBLIC_ORIGIN,
|
|
206
|
+
reader: reader ? { on: true, schedule: reader.schedule } : null,
|
|
207
|
+
}).listen(port, () => {
|
|
162
208
|
console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
|
|
163
209
|
});
|
|
164
210
|
|
|
@@ -10,6 +10,8 @@ import { xeroError } from './client.js';
|
|
|
10
10
|
// settings and are never stored. Tokens are never logged.
|
|
11
11
|
|
|
12
12
|
const FORMAT = 'v1';
|
|
13
|
+
// What a Custom Connection's row seals in place of a refresh token.
|
|
14
|
+
export const CUSTOM_CONNECTION = 'custom-connection';
|
|
13
15
|
|
|
14
16
|
function aad(organisationId) {
|
|
15
17
|
return Buffer.from(`flow:xero:refresh-token:${organisationId}`);
|
|
@@ -40,6 +40,7 @@ export const XERO_SETUP = {
|
|
|
40
40
|
},
|
|
41
41
|
fields: [
|
|
42
42
|
{ name: 'XERO_MODE', label: 'Mode', kind: 'choice', choices: [{ value: 'live', label: 'Live: your Xero organisation' }, { value: 'sandbox', label: 'Sandbox: a Xero Demo Company' }] },
|
|
43
|
+
{ name: 'XERO_CONNECTION', label: 'Xero app type', kind: 'choice', choices: [{ value: 'app', label: 'Web app' }, { value: 'custom', label: 'Custom connection (Demo Company only)' }], help: 'Custom connection: no Xero sign-in step. Sandbox mode only.' },
|
|
43
44
|
{ name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'Register the same address on the Xero app.' },
|
|
44
45
|
{ name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
|
|
45
46
|
{ name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { DEFAULT_TIMEOUT_MS, xeroError } from './client.js';
|
|
3
3
|
|
|
4
|
-
// Xero OAuth 2.0
|
|
5
|
-
//
|
|
4
|
+
// Xero OAuth 2.0: the authorisation code with offline_access, or a Custom
|
|
5
|
+
// Connection's client credentials (settings.js, XERO_CONNECTION). Bodies that
|
|
6
|
+
// carry tokens never reach a log or an error message.
|
|
6
7
|
|
|
7
8
|
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
8
9
|
|
|
@@ -58,7 +59,7 @@ async function tokenRequest(settings, fetchImpl, form, what) {
|
|
|
58
59
|
oauthError: typeof body?.error === 'string' ? body.error.slice(0, 40) : null,
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
|
-
if (!body?.access_token || !body?.refresh_token) {
|
|
62
|
+
if (!body?.access_token || (form.grant_type !== 'client_credentials' && !body?.refresh_token)) {
|
|
62
63
|
throw xeroError('xero_token_failed', `Xero's answer to ${what} had no tokens.`, { status: 502 });
|
|
63
64
|
}
|
|
64
65
|
return body;
|
|
@@ -73,6 +74,12 @@ export function refreshTokens(settings, fetchImpl, refreshToken) {
|
|
|
73
74
|
return tokenRequest(settings, fetchImpl, { grant_type: 'refresh_token', refresh_token: refreshToken }, 'the token refresh');
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// A Custom Connection's access token. There is no refresh token: Flow asks
|
|
78
|
+
// again when the token runs out.
|
|
79
|
+
export function clientCredentials(settings, fetchImpl) {
|
|
80
|
+
return tokenRequest(settings, fetchImpl, { grant_type: 'client_credentials', scope: settings.scope }, 'the Custom Connection token');
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
// Best effort: a disconnect deletes Flow's copy even when Xero is down.
|
|
77
84
|
export async function revokeToken(settings, fetchImpl, refreshToken) {
|
|
78
85
|
const basic = Buffer.from(`${settings.clientId}:${settings.clientSecret}`).toString('base64');
|
|
@@ -12,8 +12,8 @@ import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchX
|
|
|
12
12
|
import { queueHistoryImport, resetImport } from './import.js';
|
|
13
13
|
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
|
|
14
14
|
import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
15
|
-
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
16
|
-
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
15
|
+
import { CUSTOM_CONNECTION, decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
16
|
+
import { authorizeUrl, clientCredentials, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
17
17
|
import { writesAllowed } from './settings.js';
|
|
18
18
|
import { readXeroUsage } from './usage.js';
|
|
19
19
|
import { saveEvents, signatureValid } from './webhooks.js';
|
|
@@ -23,7 +23,8 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
23
23
|
// The Xero adapter's routes, mounted at /api/xero (server/integrations/port.js).
|
|
24
24
|
//
|
|
25
25
|
// GET /status?organisationId= any signed-in person: mode and connection
|
|
26
|
-
// POST /connect owner: returns Xero's approval address
|
|
26
|
+
// POST /connect owner: returns Xero's approval address, or
|
|
27
|
+
// connects a Custom Connection at once
|
|
27
28
|
// GET /oauth/callback Xero sends the owner back here
|
|
28
29
|
// POST /disconnect owner: revokes and forgets the connection
|
|
29
30
|
// POST /sync owner or admin: copies Xero contacts and items
|
|
@@ -182,8 +183,59 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
182
183
|
});
|
|
183
184
|
}));
|
|
184
185
|
|
|
186
|
+
// Keeps the one Xero organisation these tokens reach, after proving it is
|
|
187
|
+
// one Flow may use (a Demo Company in sandbox). A refused code-flow grant
|
|
188
|
+
// is revoked at Xero. Then the history import starts.
|
|
189
|
+
async function connectWith(tokens, { organisationId, personId }) {
|
|
190
|
+
const connections = await fetchConnections(settings, runtime.transport, tokens.access_token);
|
|
191
|
+
let chosen;
|
|
192
|
+
try {
|
|
193
|
+
chosen = selectConnection(connections, settings);
|
|
194
|
+
const probe = createXeroClient({
|
|
195
|
+
tenantId: chosen.tenantId,
|
|
196
|
+
accessToken: async () => tokens.access_token,
|
|
197
|
+
fetch: runtime.transport,
|
|
198
|
+
governor: runtime.governor,
|
|
199
|
+
apiBase: settings.endpoints.api,
|
|
200
|
+
log,
|
|
201
|
+
});
|
|
202
|
+
await runtime.assertEnvironment(probe);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Refused: Xero keeps no grant for an organisation Flow will not use.
|
|
205
|
+
if (tokens.refresh_token) await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
const previous = await readConnection(db, organisationId);
|
|
209
|
+
const connection = await saveConnection(db, {
|
|
210
|
+
organisationId,
|
|
211
|
+
mode: settings.mode,
|
|
212
|
+
tenantId: chosen.tenantId,
|
|
213
|
+
tenantName: chosen.tenantName ?? null,
|
|
214
|
+
scope: tokens.scope ?? settings.scope,
|
|
215
|
+
// A Custom Connection has no refresh token; the column holds a sealed
|
|
216
|
+
// marker that is never read (runtime.js asks Xero again instead).
|
|
217
|
+
refreshToken: tokens.refresh_token ?? CUSTOM_CONNECTION,
|
|
218
|
+
connectedBy: personId,
|
|
219
|
+
key: settings.tokenKey,
|
|
220
|
+
});
|
|
221
|
+
runtime.remember(organisationId, tokens);
|
|
222
|
+
// A new connection is a person acting: calls stopped before resume.
|
|
223
|
+
await runtime.guardFor(db).resume();
|
|
224
|
+
// Another Xero organisation starts its history from the beginning.
|
|
225
|
+
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, organisationId);
|
|
226
|
+
await queueHistoryImport(db, organisationId, { reason: 'connected' });
|
|
227
|
+
log.info?.(`[xero] organisation ${organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}, ${settings.connection}) by person ${personId}`);
|
|
228
|
+
return connection;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A Custom Connection connects here at once; a Xero app answers with the
|
|
232
|
+
// approval address the browser opens.
|
|
185
233
|
router.post('/connect', ...owner, handle(log, async (req, res) => {
|
|
186
234
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
235
|
+
if (settings.connection === 'custom') {
|
|
236
|
+
const connection = await connectWith(await clientCredentials(settings, runtime.transport), { organisationId, personId: req.person.id });
|
|
237
|
+
return res.json({ ok: true, connected: true, connection: describeConnection(connection), mode: settings.mode });
|
|
238
|
+
}
|
|
187
239
|
if (!settings.redirectUri) {
|
|
188
240
|
throw Object.assign(new FlowError('xero_redirect_uri_missing', 'Set FLOW_PUBLIC_ORIGIN (or XERO_REDIRECT_URI) to this installation\'s address, and register its /api/xero/oauth/callback on the Xero app.'), { status: 503 });
|
|
189
241
|
}
|
|
@@ -202,42 +254,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
202
254
|
}
|
|
203
255
|
try {
|
|
204
256
|
const tokens = await exchangeCode(settings, runtime.transport, typeof req.query.code === 'string' ? req.query.code : '');
|
|
205
|
-
const
|
|
206
|
-
let chosen;
|
|
207
|
-
try {
|
|
208
|
-
chosen = selectConnection(connections, settings);
|
|
209
|
-
const probe = createXeroClient({
|
|
210
|
-
tenantId: chosen.tenantId,
|
|
211
|
-
accessToken: async () => tokens.access_token,
|
|
212
|
-
fetch: runtime.transport,
|
|
213
|
-
governor: runtime.governor,
|
|
214
|
-
apiBase: settings.endpoints.api,
|
|
215
|
-
log,
|
|
216
|
-
});
|
|
217
|
-
await runtime.assertEnvironment(probe);
|
|
218
|
-
} catch (error) {
|
|
219
|
-
// Refused: Xero keeps no grant for an organisation Flow will not use.
|
|
220
|
-
await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
|
|
221
|
-
throw error;
|
|
222
|
-
}
|
|
223
|
-
const previous = await readConnection(db, entry.organisationId);
|
|
224
|
-
const connection = await saveConnection(db, {
|
|
225
|
-
organisationId: entry.organisationId,
|
|
226
|
-
mode: settings.mode,
|
|
227
|
-
tenantId: chosen.tenantId,
|
|
228
|
-
tenantName: chosen.tenantName ?? null,
|
|
229
|
-
scope: tokens.scope ?? settings.scope,
|
|
230
|
-
refreshToken: tokens.refresh_token,
|
|
231
|
-
connectedBy: entry.personId,
|
|
232
|
-
key: settings.tokenKey,
|
|
233
|
-
});
|
|
234
|
-
runtime.remember(entry.organisationId, tokens);
|
|
235
|
-
// A new connection is a person acting: calls stopped before resume.
|
|
236
|
-
await runtime.guardFor(db).resume();
|
|
237
|
-
// Another Xero organisation starts its history from the beginning.
|
|
238
|
-
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
|
|
239
|
-
await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
|
|
240
|
-
log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
|
|
257
|
+
const connection = await connectWith(tokens, entry);
|
|
241
258
|
const organisation = connection.tenantName || 'your Xero organisation';
|
|
242
259
|
return res.type('html').send(page(
|
|
243
260
|
'Xero is connected',
|
|
@@ -257,7 +274,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
257
274
|
const connection = await readConnection(db, organisationId);
|
|
258
275
|
if (!connection) return res.json({ ok: true, disconnected: false });
|
|
259
276
|
let revoked = false;
|
|
260
|
-
|
|
277
|
+
// A Custom Connection has no token to revoke: it ends in Xero's My Apps.
|
|
278
|
+
if (settings.connection !== 'custom') try {
|
|
261
279
|
revoked = await revokeToken(settings, runtime.transport, decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId));
|
|
262
280
|
} catch (error) {
|
|
263
281
|
log.warn?.(`[xero] could not revoke the Xero token for organisation ${organisationId}: ${error.code ?? 'error'}`);
|
|
@@ -2,7 +2,7 @@ import { createCallGuard } from '../call-guard.js';
|
|
|
2
2
|
import { createXeroClient, xeroError } from './client.js';
|
|
3
3
|
import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
|
|
4
4
|
import { createGovernor } from './governor.js';
|
|
5
|
-
import { createStateStore, refreshTokens } from './oauth.js';
|
|
5
|
+
import { clientCredentials, createStateStore, refreshTokens } from './oauth.js';
|
|
6
6
|
import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from './replay.js';
|
|
7
7
|
import { resolveXeroSettings, writesAllowed } from './settings.js';
|
|
8
8
|
|
|
@@ -101,10 +101,13 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
101
101
|
return db.transaction(async (tx) => {
|
|
102
102
|
const connection = usable(await readConnection(tx, organisationId, { lock: true }));
|
|
103
103
|
await guard.check(connection.tenantId, { resource: 'token' });
|
|
104
|
-
|
|
104
|
+
// A Custom Connection has no refresh token: Flow asks with the client
|
|
105
|
+
// id and secret again, and nothing rotates.
|
|
106
|
+
const custom = settings.connection === 'custom';
|
|
107
|
+
const refreshToken = custom ? null : decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
|
|
105
108
|
let tokens;
|
|
106
109
|
try {
|
|
107
|
-
tokens = await refreshTokens(settings, transport, refreshToken);
|
|
110
|
+
tokens = custom ? await clientCredentials(settings, transport) : await refreshTokens(settings, transport, refreshToken);
|
|
108
111
|
} catch (error) {
|
|
109
112
|
// invalid_grant: the connection is gone. invalid_client: the app's
|
|
110
113
|
// client id or secret is wrong. Neither clears without a person.
|
|
@@ -116,7 +119,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
116
119
|
throw error;
|
|
117
120
|
}
|
|
118
121
|
await guard.record(connection.tenantId, { resource: 'token', statusCode: 200 });
|
|
119
|
-
await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
|
|
122
|
+
if (!custom) await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
|
|
120
123
|
remember(organisationId, tokens);
|
|
121
124
|
return tokens.access_token;
|
|
122
125
|
});
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
// is not DEMO, so a sandbox can never write to a real ledger.
|
|
11
11
|
// replay no network: recorded, synthetic Xero responses
|
|
12
12
|
// (server/integrations/xero/fixtures). Development and CI.
|
|
13
|
+
//
|
|
14
|
+
// XERO_CONNECTION says how Flow gets its Xero access tokens.
|
|
15
|
+
// app (default) a Xero web app: an owner approves in the browser, and
|
|
16
|
+
// Flow keeps the rotating refresh token.
|
|
17
|
+
// custom a Xero Custom Connection (client credentials): no browser step
|
|
18
|
+
// and no refresh token. Sandbox only: Xero makes Custom
|
|
19
|
+
// Connections free on the Demo Company, so a demo or a test
|
|
20
|
+
// session connects with the client id and secret alone.
|
|
13
21
|
|
|
14
22
|
import { backgroundJobsOn } from '../../core/host.js';
|
|
15
23
|
|
|
@@ -29,6 +37,8 @@ export const XERO_ENDPOINTS = Object.freeze({
|
|
|
29
37
|
export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
|
|
30
38
|
// accounting.payments.read: Flow reads payments back (ledger.js, mirror.js).
|
|
31
39
|
export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read';
|
|
40
|
+
// A Custom Connection has no refresh token, so it asks for no offline_access.
|
|
41
|
+
export const DEFAULT_CUSTOM_SCOPE = DEFAULT_XERO_SCOPE.replace('offline_access ', '');
|
|
32
42
|
export const DEFAULT_XERO_DAY_LIMIT = 1000;
|
|
33
43
|
export const DEFAULT_DUE_DAYS = 30;
|
|
34
44
|
export const DEFAULT_MIRROR_PAGE_SIZE = 200;
|
|
@@ -121,6 +131,12 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
121
131
|
if (!mode) return null;
|
|
122
132
|
if (!XERO_MODES.includes(mode)) throw new Error('XERO_MODE must be "live", "sandbox", "replay" or unset');
|
|
123
133
|
|
|
134
|
+
const connection = (readEnv(env, 'XERO_CONNECTION') || 'app').toLowerCase();
|
|
135
|
+
if (!['app', 'custom'].includes(connection)) throw new Error('XERO_CONNECTION must be "app", "custom" or unset');
|
|
136
|
+
if (connection === 'custom' && mode !== 'sandbox') {
|
|
137
|
+
throw new Error('XERO_CONNECTION=custom works only with XERO_MODE=sandbox (a Xero Demo Company)');
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
const clientId = readEnv(env, 'XERO_CLIENT_ID');
|
|
125
141
|
const clientSecret = readEnv(env, 'XERO_CLIENT_SECRET');
|
|
126
142
|
const tokenKey = decodeTokenKey(readEnv(env, 'XERO_TOKEN_KEY'));
|
|
@@ -150,11 +166,12 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
150
166
|
|
|
151
167
|
return {
|
|
152
168
|
mode,
|
|
169
|
+
connection,
|
|
153
170
|
clientId: clientId || (mode === 'replay' ? 'replay-client-id' : ''),
|
|
154
171
|
clientSecret: clientSecret || (mode === 'replay' ? 'replay-client-secret' : ''),
|
|
155
172
|
tokenKey,
|
|
156
173
|
redirectUri: resolveRedirectUri(env),
|
|
157
|
-
scope: readEnv(env, 'XERO_SCOPE') || DEFAULT_XERO_SCOPE,
|
|
174
|
+
scope: readEnv(env, 'XERO_SCOPE') || (connection === 'custom' ? DEFAULT_CUSTOM_SCOPE : DEFAULT_XERO_SCOPE),
|
|
158
175
|
tenantId,
|
|
159
176
|
tenantName,
|
|
160
177
|
liveWrites: readOnOff(env, 'XERO_ALLOW_LIVE_WRITES'),
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/custom-fields'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -348,7 +348,7 @@ function renderSignedOut() {
|
|
|
348
348
|
return;
|
|
349
349
|
}
|
|
350
350
|
$('sign-in-note').textContent = config.publicSignUp
|
|
351
|
-
? '
|
|
351
|
+
? 'New here? Ask for access with your work email.'
|
|
352
352
|
: 'Sign in with the account you were invited with.';
|
|
353
353
|
// With public sign-up off the widget never creates a Clerk account: no
|
|
354
354
|
// sign-up link, and an OAuth sign-in for an unknown email is not turned
|
|
@@ -867,6 +867,7 @@ async function renderIntegration(main, id) {
|
|
|
867
867
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
868
868
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
869
869
|
on && current.state === 'ready' ? personSettingsSection(current) : null,
|
|
870
|
+
current.id === 'seazona' && current.state === 'ready' ? seazonaHistorySection() : null,
|
|
870
871
|
current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
|
|
871
872
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'xero', 'Contacts')) : null,
|
|
872
873
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
@@ -978,6 +979,56 @@ function xeroImportSection(organisations) {
|
|
|
978
979
|
return section;
|
|
979
980
|
}
|
|
980
981
|
|
|
982
|
+
// Seazona's history read (docs/SEAZONA.md, "The API backfill"): past orders
|
|
983
|
+
// from a start day, a few calls a run inside the rate limit. Start and Stop
|
|
984
|
+
// call no Seazona; the background lane reads. While it runs, the row reads
|
|
985
|
+
// its progress again every 30 seconds, from Flow only.
|
|
986
|
+
function seazonaHistorySection() {
|
|
987
|
+
const canAct = ['owner', 'admin'].includes(me?.person?.role);
|
|
988
|
+
const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
|
|
989
|
+
const from = h('input', { class: 'fx-input', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
|
|
990
|
+
const status = h('span', {}, 'Checking…');
|
|
991
|
+
const jobs = h('span', { class: 'fx-num' }, '…');
|
|
992
|
+
const control = h('span', { class: 'fx-row' });
|
|
993
|
+
const day = (value) => new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { dateStyle: 'medium', timeZone: 'UTC' });
|
|
994
|
+
const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
|
|
995
|
+
h('h2', { class: 'fx-set__title' }, 'History'),
|
|
996
|
+
settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Each job waits on hold and never drafts by itself.', control }),
|
|
997
|
+
settingRow({ label: 'Jobs read', control: jobs }));
|
|
998
|
+
const press = async (button, path, body) => {
|
|
999
|
+
button.disabled = true;
|
|
1000
|
+
try {
|
|
1001
|
+
await flow(path, { method: 'POST', ...(body ? { body: JSON.stringify(body) } : {}) });
|
|
1002
|
+
toast(body ? 'History read started.' : 'History read stopped.', 'success');
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
toast(error.message, 'danger');
|
|
1005
|
+
if (error.retryAfterMs) holdButton(button, error.retryAfterMs);
|
|
1006
|
+
}
|
|
1007
|
+
load();
|
|
1008
|
+
};
|
|
1009
|
+
const load = async () => {
|
|
1010
|
+
let body;
|
|
1011
|
+
try {
|
|
1012
|
+
body = await flow('/api/seazona/history');
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
status.replaceChildren(error.message);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const run = body.backfill;
|
|
1018
|
+
jobs.replaceChildren(String(body.history?.jobs ?? 0));
|
|
1019
|
+
if (run?.running) {
|
|
1020
|
+
status.replaceChildren(`Reading ${day(run.nextDay ?? run.from)} · ${run.ordersRead} read`);
|
|
1021
|
+
control.replaceChildren(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill/stop') }, 'Stop'));
|
|
1022
|
+
setTimeout(() => { if (section.isConnected) load(); }, 30_000);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
status.replaceChildren(run ? `${run.stopped ? 'Stopped' : 'Done'} · ${run.ordersRead} read from ${day(run.from)}` : 'Not started');
|
|
1026
|
+
control.replaceChildren(from, h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', { from: from.value }) }, 'Start'));
|
|
1027
|
+
};
|
|
1028
|
+
load();
|
|
1029
|
+
return section;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
981
1032
|
// Once Xero is on: each organisation's connection, the history copy, and
|
|
982
1033
|
// Connect, which goes to Xero and comes back.
|
|
983
1034
|
function xeroConnection(organisations, canChange) {
|
|
@@ -1014,8 +1065,11 @@ function xeroConnection(organisations, canChange) {
|
|
|
1014
1065
|
if (canChange) {
|
|
1015
1066
|
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped && !refused ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
1016
1067
|
try {
|
|
1068
|
+
// A Custom Connection connects at once; a Xero app sends the
|
|
1069
|
+
// browser to Xero to approve.
|
|
1017
1070
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
1018
|
-
location.assign(authorizeUrl);
|
|
1071
|
+
if (authorizeUrl) location.assign(authorizeUrl);
|
|
1072
|
+
else location.reload();
|
|
1019
1073
|
} catch (error) {
|
|
1020
1074
|
toast(error.message, 'danger');
|
|
1021
1075
|
}
|
|
@@ -1239,7 +1293,8 @@ drawModeToggles();
|
|
|
1239
1293
|
// the Clerk instance is called.
|
|
1240
1294
|
const LOCALIZATION = {
|
|
1241
1295
|
signIn: {
|
|
1242
|
-
|
|
1296
|
+
// titleCombined is the sign-in-or-sign-up screen (public sign-up on).
|
|
1297
|
+
start: { title: 'Sign in', titleCombined: 'Sign in', subtitle: '' },
|
|
1243
1298
|
password: { subtitle: 'Enter the password for your account.' },
|
|
1244
1299
|
},
|
|
1245
1300
|
signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
|
package/server/ui/flow.css
CHANGED
|
@@ -440,7 +440,12 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
440
440
|
.fx-toolbar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--fx-line); }
|
|
441
441
|
.fx-tabs { display: flex; gap: 2px; }
|
|
442
442
|
.fx-tabs button { flex: none; }
|
|
443
|
-
|
|
443
|
+
/* Phones and tablets: the tabs scroll in one row and each is 44px tall, so
|
|
444
|
+
seven tabs never run past the page and a thumb hits each one. */
|
|
445
|
+
@media (max-width: 1023px) {
|
|
446
|
+
.fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
|
|
447
|
+
.fx-tabs button { min-height: 44px; }
|
|
448
|
+
}
|
|
444
449
|
.fx-tabs button {
|
|
445
450
|
display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: -1px;
|
|
446
451
|
border: 0; border-bottom: 2px solid transparent; background: transparent; cursor: pointer;
|
|
@@ -1346,7 +1351,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1346
1351
|
/* Touch: every control a thumb uses is 44px (docs/MOBILE_TESTS.md). The
|
|
1347
1352
|
icons and text keep their size; the box around them grows. */
|
|
1348
1353
|
#menu-toggle, #search { width: 44px; height: 44px; }
|
|
1349
|
-
.fx-tabs button { min-height: 44px; }
|
|
1350
1354
|
/* The notice's buttons grow to 44px into its padding, so the notice
|
|
1351
1355
|
stays one short line. */
|
|
1352
1356
|
.fx-alerts__close { width: 44px; height: 44px; margin: -12px -8px; }
|
|
@@ -14,7 +14,7 @@ const TEST_LABELS = {
|
|
|
14
14
|
is_not_one_of: 'is not one of', contains: 'contains', does_not_contain: 'does not contain',
|
|
15
15
|
starts_with: 'starts with', does_not_start_with: 'does not start with', has_number: 'has a number', number_in_words: 'has a number beside words', one_word: 'is one word',
|
|
16
16
|
};
|
|
17
|
-
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note' };
|
|
17
|
+
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note', non_chargeable: 'Non-chargeable' };
|
|
18
18
|
const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word'];
|
|
19
19
|
const MANY = ['is_one_of', 'is_not_one_of'];
|
|
20
20
|
const TYPED = ['contains', 'does_not_contain', 'starts_with', 'does_not_start_with'];
|