@wtfalch/foundry 0.1.0 → 0.3.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/dist/apply.d.ts +14 -0
- package/dist/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +175 -0
- package/dist/calls-sfu.js +239 -0
- package/dist/index.d.ts +11 -6
- package/dist/index.js +7 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0008_hosting.sql +24 -0
- package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
- package/dist/migrations/0010_owner.sql +13 -0
- package/dist/migrations/0011_test_org.sql +12 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/org.d.ts +130 -0
- package/dist/org.js +102 -0
- package/dist/plan/diff.d.ts +20 -5
- package/dist/plan/diff.js +533 -9
- package/dist/plan/executor.d.ts +823 -0
- package/dist/plan/executor.js +1242 -0
- package/dist/plan/index.d.ts +6 -4
- package/dist/plan/index.js +3 -2
- package/dist/plan/live.d.ts +8 -4
- package/dist/plan/plan-apply.d.ts +114 -4
- package/dist/plan/plan-apply.js +266 -14
- package/dist/plan/providers-readers.d.ts +102 -14
- package/dist/plan/providers-readers.js +245 -16
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +66 -0
- package/dist/state.d.ts +26 -5
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +3 -1
- package/dist/store/postgres.js +84 -2
- package/dist/store/schema.d.ts +378 -0
- package/dist/store/schema.js +32 -1
- package/dist/store.d.ts +12 -0
- package/package.json +15 -11
package/dist/slug.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkName } from '@wtfalch/auth/names';
|
|
1
2
|
/**
|
|
2
3
|
* The declared org's slug: a pure validator, no I/O, no store access.
|
|
3
4
|
*
|
|
@@ -139,3 +140,68 @@ export function validateSlug(slug) {
|
|
|
139
140
|
}
|
|
140
141
|
return null;
|
|
141
142
|
}
|
|
143
|
+
/** Letters a plain accent strip gets wrong, mostly Norwegian. */
|
|
144
|
+
const SPELLED_OUT = {
|
|
145
|
+
æ: 'ae',
|
|
146
|
+
ø: 'o',
|
|
147
|
+
å: 'a',
|
|
148
|
+
ß: 'ss',
|
|
149
|
+
œ: 'oe',
|
|
150
|
+
þ: 'th',
|
|
151
|
+
ð: 'd',
|
|
152
|
+
ı: 'i',
|
|
153
|
+
ł: 'l',
|
|
154
|
+
đ: 'd',
|
|
155
|
+
};
|
|
156
|
+
/** Room left for `-xxxx` after the base, so a suffixed slug stays one DNS label. */
|
|
157
|
+
const BASE_MAX = 58;
|
|
158
|
+
function randomSuffix() {
|
|
159
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
160
|
+
let out = '';
|
|
161
|
+
for (let i = 0; i < 4; i++)
|
|
162
|
+
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The sign-in broker's name check, with the length and character rules
|
|
167
|
+
* loosened: a display name has spaces and accents, and a slug may be 63 long.
|
|
168
|
+
* Only its `reserved` and `disallowed` verdicts apply here.
|
|
169
|
+
*/
|
|
170
|
+
const ANY_TEXT = { minLength: 0, maxLength: Number.MAX_SAFE_INTEGER, pattern: /^/ };
|
|
171
|
+
/**
|
|
172
|
+
* The slug for a new org, derived from its first display name (William,
|
|
173
|
+
* 2026-09-26): "Good Company" becomes `good-company`, and `good-company.oikos.host`
|
|
174
|
+
* is its address. When that slug is taken, reserved or has an environment
|
|
175
|
+
* suffix, a short random suffix is added: `bruh-7k3q`. The display name stays
|
|
176
|
+
* separate, so an org can rename itself without moving its address.
|
|
177
|
+
*
|
|
178
|
+
* An offensive name is refused outright, by the same check the sign-in broker
|
|
179
|
+
* runs on usernames (https://github.com/wtfalch/foundry/issues/103, N1b). A
|
|
180
|
+
* name that could pass as infra (`admin`, `help`) only costs the slug a suffix.
|
|
181
|
+
*/
|
|
182
|
+
export async function slugFromName(name, slugTaken, suffix = randomSuffix) {
|
|
183
|
+
const base = name
|
|
184
|
+
.toLowerCase()
|
|
185
|
+
.replace(/[æøåßœþðıłđ]/g, (c) => SPELLED_OUT[c] ?? c)
|
|
186
|
+
.normalize('NFKD')
|
|
187
|
+
.replace(/\p{M}/gu, '')
|
|
188
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
189
|
+
.replace(/^-+|-+$/g, '')
|
|
190
|
+
.slice(0, BASE_MAX)
|
|
191
|
+
.replace(/-+$/, '') || 'org';
|
|
192
|
+
const nameVerdict = checkName(name, ANY_TEXT);
|
|
193
|
+
const baseVerdict = checkName(base, ANY_TEXT);
|
|
194
|
+
if ((!nameVerdict.ok && nameVerdict.reason === 'disallowed') ||
|
|
195
|
+
(!baseVerdict.ok && baseVerdict.reason === 'disallowed')) {
|
|
196
|
+
throw new Error(`org name '${name}' is not allowed`);
|
|
197
|
+
}
|
|
198
|
+
const free = async (slug) => validateSlug(slug) === null && checkName(slug, ANY_TEXT).ok && !(await slugTaken(slug));
|
|
199
|
+
if (await free(base))
|
|
200
|
+
return base;
|
|
201
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
202
|
+
const candidate = `${base}-${suffix()}`;
|
|
203
|
+
if (await free(candidate))
|
|
204
|
+
return candidate;
|
|
205
|
+
}
|
|
206
|
+
throw new Error(`no free slug found for '${name}' after 10 tries`);
|
|
207
|
+
}
|
package/dist/state.d.ts
CHANGED
|
@@ -34,17 +34,38 @@ export interface LastApplied {
|
|
|
34
34
|
export interface AppliedState {
|
|
35
35
|
orgId: OrgId;
|
|
36
36
|
/**
|
|
37
|
-
* One id per
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* One id per resource `plan/plan-apply.ts`'s `runSteps` has made true,
|
|
38
|
+
* keyed `system:kind:key` — the same resource key `plan/diff.ts` plans
|
|
39
|
+
* against (e.g. `coolify:app:<orgId>`, `zitadel:app:<name>`), so two
|
|
40
|
+
* resources that share a `kind` (an org's several declared ZITADEL apps,
|
|
41
|
+
* #71) never overwrite each other's id the way a bare `kind` key did.
|
|
42
|
+
* `plan/providers-readers.ts`'s `storeRecordedIds` reads this map back to
|
|
43
|
+
* match a live provider resource to the foundry resource it was recorded
|
|
44
|
+
* for, instead of reporting it unmanaged (design:
|
|
45
|
+
* `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for
|
|
46
|
+
* (1)").
|
|
47
|
+
*
|
|
48
|
+
* A provider *scope* id — `coolify_project`, `zitadel_org`,
|
|
49
|
+
* `zitadel_project`, a Cloudflare zone id (`plan/providers-readers.ts`'s
|
|
50
|
+
* `ScopeResolver`, https://github.com/wtfalch/foundry/issues/14's resolved
|
|
51
|
+
* research #4) — is a different id space and keeps its own plain key; a
|
|
52
|
+
* scope key has no `:` in it, so it can never collide with the
|
|
53
|
+
* `system:kind:key` form. `plan/executor.ts`'s `FoundryStepExecutor` is
|
|
54
|
+
* what writes these today, through `plan-apply.ts`'s
|
|
55
|
+
* `StepExecutionContext.recordScopeId` — backed by the very `serviceIds`
|
|
56
|
+
* map `runSteps` builds here and persists in one write at the end of a
|
|
57
|
+
* run, not a separate store write of its own (`StepExecutionContext`'s doc
|
|
58
|
+
* comment explains why: a second writer would race `runSteps`' own final
|
|
59
|
+
* one).
|
|
41
60
|
*/
|
|
42
61
|
serviceIds: Record<string, string>;
|
|
43
62
|
/** Checkpoints from the current or most recent apply. */
|
|
44
63
|
checkpoints: Checkpoint[];
|
|
45
64
|
/**
|
|
46
65
|
* The name of every {@link DeclaredOrg} field the most recent apply could
|
|
47
|
-
* not make true
|
|
66
|
+
* not make true: because nothing implements it yet, or because its step
|
|
67
|
+
* had not completed when the apply failed. `apply` recomputes it every
|
|
68
|
+
* time (#25), in `plan/plan-apply.ts`'s `failedFields`.
|
|
48
69
|
*
|
|
49
70
|
* Decision (#18): this field did not exist on the type this ticket found.
|
|
50
71
|
* #7 ("Shape") and #18's "Done when" both require `apply` to "fail loudly
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AuditLog } from '../plan/plan-apply.js';
|
|
2
|
+
import type { FoundryStoreDb } from './postgres.js';
|
|
3
|
+
/**
|
|
4
|
+
* foundry's own {@link AuditLog}, one row per plan, apply or destroy in
|
|
5
|
+
* `foundry_audit_events` (#85). The CLI uses it before any archon exists; an
|
|
6
|
+
* attached archon reads the same table. It only inserts.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createPostgresAuditLog(db: FoundryStoreDb): AuditLog;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { foundryAuditEvents } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* foundry's own {@link AuditLog}, one row per plan, apply or destroy in
|
|
4
|
+
* `foundry_audit_events` (#85). The CLI uses it before any archon exists; an
|
|
5
|
+
* attached archon reads the same table. It only inserts.
|
|
6
|
+
*/
|
|
7
|
+
export function createPostgresAuditLog(db) {
|
|
8
|
+
return {
|
|
9
|
+
async recordPlan({ actor, orgId, plan, at }) {
|
|
10
|
+
await db.insert(foundryAuditEvents).values({
|
|
11
|
+
at,
|
|
12
|
+
kind: 'plan',
|
|
13
|
+
actorId: actor.id,
|
|
14
|
+
actorDisplay: actor.display,
|
|
15
|
+
orgId,
|
|
16
|
+
plan,
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
async recordApply({ actor, orgId, plan, outcome, reason, at }) {
|
|
20
|
+
await db.insert(foundryAuditEvents).values({
|
|
21
|
+
at,
|
|
22
|
+
kind: 'apply',
|
|
23
|
+
actorId: actor.id,
|
|
24
|
+
actorDisplay: actor.display,
|
|
25
|
+
orgId,
|
|
26
|
+
outcome,
|
|
27
|
+
reason: reason ?? null,
|
|
28
|
+
plan,
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
async recordDestroy({ actor, orgId, outcome, reason, at }) {
|
|
32
|
+
await db.insert(foundryAuditEvents).values({
|
|
33
|
+
at,
|
|
34
|
+
kind: 'destroy',
|
|
35
|
+
actorId: actor.id,
|
|
36
|
+
actorDisplay: actor.display,
|
|
37
|
+
orgId,
|
|
38
|
+
outcome,
|
|
39
|
+
reason: reason ?? null,
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -34,7 +34,9 @@ export declare class DeclaredOrgError extends Error {
|
|
|
34
34
|
* on both `create` and `update`. #27 adds a third, narrower rule: storage and
|
|
35
35
|
* AI spend limits are bigint-safe decimal strings of non-negative integers
|
|
36
36
|
* (or `null` for uncapped), checked the same way on both `create` and the
|
|
37
|
-
* merged result of `update`.
|
|
37
|
+
* merged result of `update`. #67 adds a fourth (`org.ts`'s
|
|
38
|
+
* `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
|
|
39
|
+
* checked the same way. The broader "combinations foundry knows are
|
|
38
40
|
* impossible are rejected with a reason" line needs a ruleset (domain
|
|
39
41
|
* requirements per service, `adoptedApp.form`, mail-level transitions…)
|
|
40
42
|
* that later tickets, not #18, #27 or #28, decide; `update` still merges and
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { eq, sql } from 'drizzle-orm';
|
|
2
|
-
import { validateBuyerIdentity } from '../org.js';
|
|
2
|
+
import { validateBuyerIdentity, validateHosting, validateOwner, validateSite } from '../org.js';
|
|
3
3
|
import { validateSlug } from '../slug.js';
|
|
4
4
|
import { declaredOrgs, foundryAppliedState, } from './schema.js';
|
|
5
5
|
const UNIQUE_VIOLATION = '23505';
|
|
@@ -50,6 +50,16 @@ function rowToOrg(row) {
|
|
|
50
50
|
calls: { usesDomain: row.callsUsesDomain },
|
|
51
51
|
issuer: { usesDomain: row.issuerUsesDomain },
|
|
52
52
|
site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
|
|
53
|
+
hosting: {
|
|
54
|
+
enabled: row.hostingEnabled,
|
|
55
|
+
usesDomain: row.hostingUsesDomain,
|
|
56
|
+
database: row.hostingDatabase,
|
|
57
|
+
tunnel: row.hostingTunnel,
|
|
58
|
+
gitBranch: row.hostingGitBranch,
|
|
59
|
+
buildPack: row.hostingBuildPack,
|
|
60
|
+
issuerApps: row.hostingIssuerApps,
|
|
61
|
+
accessEmails: row.hostingAccessEmails,
|
|
62
|
+
},
|
|
53
63
|
sso: { enabled: row.ssoEnabled },
|
|
54
64
|
guests: { enabled: row.guestsEnabled },
|
|
55
65
|
solo: { enabled: row.soloEnabled },
|
|
@@ -60,6 +70,8 @@ function rowToOrg(row) {
|
|
|
60
70
|
adoptedApp: row.adoptedAppForm
|
|
61
71
|
? { form: row.adoptedAppForm }
|
|
62
72
|
: null,
|
|
73
|
+
owner: rowToOwner(row),
|
|
74
|
+
test: row.isTest,
|
|
63
75
|
};
|
|
64
76
|
}
|
|
65
77
|
function orgToRow(org) {
|
|
@@ -75,6 +87,14 @@ function orgToRow(org) {
|
|
|
75
87
|
issuerUsesDomain: org.issuer.usesDomain,
|
|
76
88
|
siteEnabled: org.site.enabled,
|
|
77
89
|
siteUsesDomain: org.site.usesDomain,
|
|
90
|
+
hostingEnabled: org.hosting.enabled,
|
|
91
|
+
hostingUsesDomain: org.hosting.usesDomain,
|
|
92
|
+
hostingDatabase: org.hosting.database,
|
|
93
|
+
hostingTunnel: org.hosting.tunnel,
|
|
94
|
+
hostingGitBranch: org.hosting.gitBranch,
|
|
95
|
+
hostingBuildPack: org.hosting.buildPack,
|
|
96
|
+
hostingIssuerApps: org.hosting.issuerApps,
|
|
97
|
+
hostingAccessEmails: org.hosting.accessEmails,
|
|
78
98
|
ssoEnabled: org.sso.enabled,
|
|
79
99
|
guestsEnabled: org.guests.enabled,
|
|
80
100
|
soloEnabled: org.solo.enabled,
|
|
@@ -90,6 +110,10 @@ function orgToRow(org) {
|
|
|
90
110
|
vatRegistered: org.buyer.vatRegistered,
|
|
91
111
|
bespokeAppRepo: org.bespokeAppRepo,
|
|
92
112
|
adoptedAppForm: org.adoptedApp?.form ?? null,
|
|
113
|
+
ownerEmail: org.owner?.email ?? null,
|
|
114
|
+
ownerGivenName: org.owner?.givenName ?? null,
|
|
115
|
+
ownerFamilyName: org.owner?.familyName ?? null,
|
|
116
|
+
isTest: org.test,
|
|
93
117
|
};
|
|
94
118
|
}
|
|
95
119
|
/**
|
|
@@ -117,6 +141,17 @@ function rowToBuyer(row) {
|
|
|
117
141
|
vatRegistered: row.vatRegistered,
|
|
118
142
|
};
|
|
119
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Reassembles `DeclaredOrg.owner` from the row's three separate nullable
|
|
146
|
+
* columns -- present only when all three are, same "all parts or none"
|
|
147
|
+
* pattern {@link rowToBuyer} uses for the buyer's address.
|
|
148
|
+
*/
|
|
149
|
+
function rowToOwner(row) {
|
|
150
|
+
const { ownerEmail, ownerGivenName, ownerFamilyName } = row;
|
|
151
|
+
if (ownerEmail === null || ownerGivenName === null || ownerFamilyName === null)
|
|
152
|
+
return null;
|
|
153
|
+
return { email: ownerEmail, givenName: ownerGivenName, familyName: ownerFamilyName };
|
|
154
|
+
}
|
|
120
155
|
/**
|
|
121
156
|
* `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and
|
|
122
157
|
* edit a {@link DeclaredOrg}, and read and write the {@link AppliedState}
|
|
@@ -129,7 +164,9 @@ function rowToBuyer(row) {
|
|
|
129
164
|
* on both `create` and `update`. #27 adds a third, narrower rule: storage and
|
|
130
165
|
* AI spend limits are bigint-safe decimal strings of non-negative integers
|
|
131
166
|
* (or `null` for uncapped), checked the same way on both `create` and the
|
|
132
|
-
* merged result of `update`.
|
|
167
|
+
* merged result of `update`. #67 adds a fourth (`org.ts`'s
|
|
168
|
+
* `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
|
|
169
|
+
* checked the same way. The broader "combinations foundry knows are
|
|
133
170
|
* impossible are rejected with a reason" line needs a ruleset (domain
|
|
134
171
|
* requirements per service, `adoptedApp.form`, mail-level transitions…)
|
|
135
172
|
* that later tickets, not #18, #27 or #28, decide; `update` still merges and
|
|
@@ -169,11 +206,35 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
169
206
|
if (reason)
|
|
170
207
|
throw new DeclaredOrgError(`buyer identity invalid: ${reason} (#67)`);
|
|
171
208
|
}
|
|
209
|
+
// #71: same place `requireValidBuyer` checks the buyer identity (#67), on
|
|
210
|
+
// both `create` and the merged result of `update`.
|
|
211
|
+
function requireValidHosting(org) {
|
|
212
|
+
const reason = validateHosting(org.hosting);
|
|
213
|
+
if (reason)
|
|
214
|
+
throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
|
|
215
|
+
}
|
|
216
|
+
// #101: same place `requireValidHosting` checks hosting, on both `create`
|
|
217
|
+
// and the merged result of `update`.
|
|
218
|
+
function requireValidSite(org) {
|
|
219
|
+
const reason = validateSite(org);
|
|
220
|
+
if (reason)
|
|
221
|
+
throw new DeclaredOrgError(`site invalid: ${reason} (#101)`);
|
|
222
|
+
}
|
|
223
|
+
// Same place `requireValidHosting` checks hosting, on both `create` and
|
|
224
|
+
// the merged result of `update`.
|
|
225
|
+
function requireValidOwner(org) {
|
|
226
|
+
const reason = validateOwner(org.owner);
|
|
227
|
+
if (reason)
|
|
228
|
+
throw new DeclaredOrgError(`owner invalid: ${reason}`);
|
|
229
|
+
}
|
|
172
230
|
return {
|
|
173
231
|
async create(org) {
|
|
174
232
|
requireValidSlug(org.slug);
|
|
175
233
|
requireValidLimits(org);
|
|
176
234
|
requireValidBuyer(org);
|
|
235
|
+
requireValidHosting(org);
|
|
236
|
+
requireValidSite(org);
|
|
237
|
+
requireValidOwner(org);
|
|
177
238
|
try {
|
|
178
239
|
await db.insert(declaredOrgs).values(orgToRow(org));
|
|
179
240
|
}
|
|
@@ -192,6 +253,14 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
192
253
|
const [row] = await db.select().from(declaredOrgs).where(eq(declaredOrgs.id, id)).limit(1);
|
|
193
254
|
return row ? rowToOrg(row) : null;
|
|
194
255
|
},
|
|
256
|
+
async slugTaken(slug) {
|
|
257
|
+
const [row] = await db
|
|
258
|
+
.select({ id: declaredOrgs.id })
|
|
259
|
+
.from(declaredOrgs)
|
|
260
|
+
.where(eq(declaredOrgs.slug, slug))
|
|
261
|
+
.limit(1);
|
|
262
|
+
return row !== undefined;
|
|
263
|
+
},
|
|
195
264
|
async update(id, patch) {
|
|
196
265
|
const current = rowToOrg(await requireRow(id));
|
|
197
266
|
// The slug freeze (#7 corrections 3): once mail exists, the mail
|
|
@@ -212,6 +281,9 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
212
281
|
const next = { ...current, ...patch, id: current.id };
|
|
213
282
|
requireValidLimits(next);
|
|
214
283
|
requireValidBuyer(next);
|
|
284
|
+
requireValidHosting(next);
|
|
285
|
+
requireValidSite(next);
|
|
286
|
+
requireValidOwner(next);
|
|
215
287
|
try {
|
|
216
288
|
await db
|
|
217
289
|
.update(declaredOrgs)
|
|
@@ -263,5 +335,15 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
263
335
|
.values(values)
|
|
264
336
|
.onConflictDoUpdate({ target: foundryAppliedState.orgId, set: values });
|
|
265
337
|
},
|
|
338
|
+
async delete(id) {
|
|
339
|
+
// Applied state first: `0002_applied_state.sql`'s
|
|
340
|
+
// `foundry_applied_state_org_fkey` references the declared org with no
|
|
341
|
+
// cascade, so deleting the org first would fail while its applied
|
|
342
|
+
// state row still points at it. Both deletes are no-ops (idempotent)
|
|
343
|
+
// when the row is already gone, matching this method's own doc
|
|
344
|
+
// comment on `../store.ts`.
|
|
345
|
+
await db.delete(foundryAppliedState).where(eq(foundryAppliedState.orgId, id));
|
|
346
|
+
await db.delete(declaredOrgs).where(eq(declaredOrgs.id, id));
|
|
347
|
+
},
|
|
266
348
|
};
|
|
267
349
|
}
|