@wtfalch/foundry 0.32.0 → 0.34.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/bootstrap/bootstrap.d.ts +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- package/dist/bootstrap/versions.d.ts +4 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +41 -0
- package/dist/index.d.ts +18 -8
- package/dist/index.js +8 -3
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/org.d.ts +24 -2
- package/dist/org.js +42 -0
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +17 -0
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +14 -1
- package/dist/plan/diff.d.ts +32 -1
- package/dist/plan/diff.js +383 -26
- package/dist/plan/executor.d.ts +177 -7
- package/dist/plan/executor.js +738 -51
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/held-key-refs.d.ts +10 -0
- package/dist/plan/held-key-refs.js +10 -0
- package/dist/plan/index.d.ts +16 -6
- package/dist/plan/index.js +8 -3
- package/dist/plan/live.d.ts +5 -2
- package/dist/plan/live.js +15 -2
- package/dist/plan/locked-hostnames.d.ts +5 -2
- package/dist/plan/locked-hostnames.js +9 -1
- package/dist/plan/login-host.d.ts +62 -2
- package/dist/plan/login-host.js +104 -1
- package/dist/plan/mail-relay.d.ts +120 -0
- package/dist/plan/mail-relay.js +323 -0
- package/dist/plan/org-names.d.ts +24 -0
- package/dist/plan/org-names.js +52 -0
- package/dist/plan/own-diff.d.ts +169 -0
- package/dist/plan/own-diff.js +432 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-limits.d.ts +22 -0
- package/dist/plan/own-limits.js +101 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +167 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +255 -0
- package/dist/plan/plan-apply.d.ts +101 -7
- package/dist/plan/plan-apply.js +216 -12
- package/dist/plan/platform-diff.d.ts +61 -12
- package/dist/plan/platform-diff.js +140 -34
- package/dist/plan/platform-org.d.ts +36 -0
- package/dist/plan/platform-org.js +96 -2
- package/dist/plan/platform-providers.d.ts +407 -12
- package/dist/plan/platform-providers.js +947 -422
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +151 -0
- package/dist/plan/zitadel-login-policy.js +1 -1
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/plan.d.ts +18 -0
- package/dist/platform.d.ts +8 -0
- package/dist/slug.js +1 -0
- package/dist/store/postgres.js +51 -2
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +5 -0
- package/package.json +1 -1
- package/versions.json +3 -1
package/dist/plan/executor.d.ts
CHANGED
|
@@ -12,8 +12,10 @@ import { type EdgePlatformTunnels, type TunnelPort } from './edge-tunnels.js';
|
|
|
12
12
|
import { type EdgePlatformZones, type ZonePort } from './edge-zones.js';
|
|
13
13
|
import { type LoginHostBindingsPort } from './login-host.js';
|
|
14
14
|
import { type MailCopyPort } from './mail-copy.js';
|
|
15
|
+
import { type OwnServicesPort } from './own-services.js';
|
|
15
16
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
16
17
|
import type { EdgePortName } from './platform-org.js';
|
|
18
|
+
export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
|
|
17
19
|
/**
|
|
18
20
|
* The Coolify calls this executor makes, shaped to match
|
|
19
21
|
* `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
|
|
@@ -36,6 +38,12 @@ export interface CoolifyPort {
|
|
|
36
38
|
name: string;
|
|
37
39
|
description?: string | null;
|
|
38
40
|
} | null>;
|
|
41
|
+
/** A project's environments (`CoolifyClient.listEnvironments`), read-only. */
|
|
42
|
+
listEnvironments(projectUuid: string): Promise<Array<{
|
|
43
|
+
id: number;
|
|
44
|
+
uuid: string;
|
|
45
|
+
name: string;
|
|
46
|
+
}>>;
|
|
39
47
|
createProject(name: string, description?: string): Promise<{
|
|
40
48
|
uuid: string;
|
|
41
49
|
}>;
|
|
@@ -100,6 +108,9 @@ export interface CoolifyPort {
|
|
|
100
108
|
custom_network_aliases?: string | null;
|
|
101
109
|
health_check_enabled?: boolean;
|
|
102
110
|
ports_exposes?: string;
|
|
111
|
+
/** Coolify's own record of the resource limits (`plan/own-limits.ts`). */
|
|
112
|
+
limits_memory?: string;
|
|
113
|
+
limits_cpus?: string;
|
|
103
114
|
}>;
|
|
104
115
|
createGithubAppApplication(input: {
|
|
105
116
|
project_uuid: string;
|
|
@@ -155,6 +166,8 @@ export interface CoolifyPort {
|
|
|
155
166
|
uuid: string;
|
|
156
167
|
internal_db_url?: string;
|
|
157
168
|
status?: string;
|
|
169
|
+
limits_memory?: string;
|
|
170
|
+
limits_cpus?: string;
|
|
158
171
|
}>;
|
|
159
172
|
/**
|
|
160
173
|
* Every database Coolify holds. `coolifyDatabase` finds its own by name and
|
|
@@ -878,10 +891,22 @@ export interface ZitadelPort {
|
|
|
878
891
|
givenName: string;
|
|
879
892
|
familyName: string;
|
|
880
893
|
email: string;
|
|
881
|
-
|
|
894
|
+
username?: string;
|
|
895
|
+
}, urlTemplate?: string, returnCode?: boolean): Promise<{
|
|
882
896
|
userId: string;
|
|
883
897
|
created: boolean;
|
|
898
|
+
inviteCode?: string;
|
|
884
899
|
}>;
|
|
900
|
+
/** Adds a domain to the org. Idempotent: a domain already on the org is a no-op. */
|
|
901
|
+
addOrgDomain(orgId: string, domain: string): Promise<void>;
|
|
902
|
+
/** Makes a domain already on the org its primary one. Idempotent: no-op when it already is. */
|
|
903
|
+
setPrimaryOrgDomain(orgId: string, domain: string): Promise<void>;
|
|
904
|
+
setUserMetadata(orgId: string, userId: string, key: string, value: string): Promise<void>;
|
|
905
|
+
getUserMetadata(orgId: string, userId: string, key: string): Promise<string | null>;
|
|
906
|
+
findUserByEmail(orgId: string, email: string): Promise<{
|
|
907
|
+
userId: string;
|
|
908
|
+
state: string;
|
|
909
|
+
} | null>;
|
|
885
910
|
/**
|
|
886
911
|
* Adds a role to a project. Idempotent: a role that already exists is not
|
|
887
912
|
* an error. `@wtfalch/providers`' `ZitadelClient` has no project-role API,
|
|
@@ -1125,6 +1150,16 @@ export interface ZitadelListAppsRawApp {
|
|
|
1125
1150
|
* an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins.
|
|
1126
1151
|
*/
|
|
1127
1152
|
export interface FilesOrganisationPort {
|
|
1153
|
+
/**
|
|
1154
|
+
* An own files host's R2 storage key, sent to that host once (the files route
|
|
1155
|
+
* `PUT /v1/manage/r2-credentials`). Absent on a port that cannot: the own R2 key step is then
|
|
1156
|
+
* blocked by name. The same pair may be sent again after a failed send.
|
|
1157
|
+
*/
|
|
1158
|
+
storeR2Credentials?(input: {
|
|
1159
|
+
accessKeyId: string;
|
|
1160
|
+
secretAccessKey: string;
|
|
1161
|
+
tokenId: string;
|
|
1162
|
+
}): Promise<void>;
|
|
1128
1163
|
provisionOrganisation(input: {
|
|
1129
1164
|
organisationId: string;
|
|
1130
1165
|
ceilingBytes: string | null;
|
|
@@ -1428,6 +1463,12 @@ export interface PlatformOrgPortSource {
|
|
|
1428
1463
|
}
|
|
1429
1464
|
export interface FoundryStepExecutorPorts {
|
|
1430
1465
|
coolify: CoolifyPort;
|
|
1466
|
+
/**
|
|
1467
|
+
* Ports for the instances an own org (`services.own`) runs itself. Without
|
|
1468
|
+
* it, a step for an own org is refused rather than run against the shared
|
|
1469
|
+
* services, and the planner reports the org's own services blocked.
|
|
1470
|
+
*/
|
|
1471
|
+
ownServices?: OwnServicesPort;
|
|
1431
1472
|
zitadel: ZitadelPort;
|
|
1432
1473
|
mail: MailPort;
|
|
1433
1474
|
lettermint: LettermintPort;
|
|
@@ -1456,6 +1497,16 @@ export interface FoundryStepExecutorPorts {
|
|
|
1456
1497
|
* compilation over one dependency nothing else needs.
|
|
1457
1498
|
*/
|
|
1458
1499
|
mailCopy?: MailCopyPort;
|
|
1500
|
+
/**
|
|
1501
|
+
* Mails an owner's first-sign-in link to their personal address. The link
|
|
1502
|
+
* carries the one-time invite code `inviteOrgOwner` returned, so an error
|
|
1503
|
+
* from `send` must not repeat `link`. Optional: without it the planner
|
|
1504
|
+
* reports each owner's invite as blocked, because the owner's work address
|
|
1505
|
+
* is not a mailbox they can open yet.
|
|
1506
|
+
*/
|
|
1507
|
+
ownerInviteMail?: {
|
|
1508
|
+
send(to: string, link: string): Promise<void>;
|
|
1509
|
+
};
|
|
1459
1510
|
/**
|
|
1460
1511
|
* #463: the cross-homeserver copy chat's forward re-key needs -- see
|
|
1461
1512
|
* {@link ChatRekeyPort}'s own doc comment. Optional the same way
|
|
@@ -1661,12 +1712,36 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1661
1712
|
private edgeRateLimits;
|
|
1662
1713
|
private edgeZones;
|
|
1663
1714
|
private edgeWriteSlot;
|
|
1715
|
+
/** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
|
|
1716
|
+
private readonly handledSecrets;
|
|
1664
1717
|
constructor(ports: FoundryStepExecutorPorts);
|
|
1718
|
+
secretsHandled(): readonly string[];
|
|
1719
|
+
/**
|
|
1720
|
+
* An own org's step runs on a copy of this executor whose ZITADEL, mail,
|
|
1721
|
+
* broker, files, jobs and calls ports are the org's own. A port the org has
|
|
1722
|
+
* not got yet is absent or throws, never the shared one.
|
|
1723
|
+
*/
|
|
1724
|
+
private executeScoped;
|
|
1725
|
+
/** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
|
|
1726
|
+
ownMailKeyMintable(): boolean;
|
|
1727
|
+
/** What the executor's own files dependencies supply, for the planner's blocked lines. */
|
|
1728
|
+
ownFilesDeps(): {
|
|
1729
|
+
keys: boolean;
|
|
1730
|
+
bucket: boolean;
|
|
1731
|
+
};
|
|
1732
|
+
/** Which ports the own files storage key step needs this executor has, for the planner's blocked line. */
|
|
1733
|
+
ownFilesR2Ports(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<{
|
|
1734
|
+
r2Keys: boolean;
|
|
1735
|
+
storeR2Credentials: boolean;
|
|
1736
|
+
}>;
|
|
1737
|
+
/** Whether the host's scoped ports for this own org include a files-organisation port. A held key still missing counts as no port. */
|
|
1738
|
+
ownFilesOrganisationPort(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;
|
|
1665
1739
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
1666
1740
|
ownEdgePorts(): Partial<Record<EdgePortName, boolean>>;
|
|
1667
1741
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
1668
1742
|
resourceId?: string;
|
|
1669
1743
|
} | undefined>;
|
|
1744
|
+
private run;
|
|
1670
1745
|
/**
|
|
1671
1746
|
* `plan/diff.ts`'s `planAccess` (#246): puts a `hosting.access` rule's
|
|
1672
1747
|
* hostname behind a Cloudflare Access app named
|
|
@@ -1765,12 +1840,30 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1765
1840
|
private teardownWorker;
|
|
1766
1841
|
private zitadelIssuer;
|
|
1767
1842
|
/**
|
|
1768
|
-
* `plan/diff.ts`'s `
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1843
|
+
* `plan/diff.ts`'s `planOrgRegistrationClosed`: closes self-registration on
|
|
1844
|
+
* the recorded ZITADEL org, for an org made before foundry did this or whose
|
|
1845
|
+
* close failed in the issuer step. Recorded only after the close returned.
|
|
1846
|
+
*/
|
|
1847
|
+
private zitadelRegistrationClosed;
|
|
1848
|
+
/**
|
|
1849
|
+
* `plan/diff.ts`'s `planOrgDomain`: registers the org's sign-in domain on
|
|
1850
|
+
* its recorded ZITADEL org and makes it primary, for an org whose ZITADEL
|
|
1851
|
+
* org was made before foundry did this. Records the domain only after both
|
|
1852
|
+
* calls succeeded.
|
|
1853
|
+
*/
|
|
1854
|
+
private zitadelDomain;
|
|
1855
|
+
/**
|
|
1856
|
+
* `plan/diff.ts`'s `planOwners`: makes the owner's account in the org's
|
|
1857
|
+
* ZITADEL org and mails the first-sign-in link to their personal address.
|
|
1858
|
+
* The account's username is the local part of `mailboxAddress` and its email
|
|
1859
|
+
* is `mailboxAddress`, so the owner signs in as `name@<org domain>`; the
|
|
1860
|
+
* personal address is kept as the `personal_email` metadata. The invite code
|
|
1861
|
+
* ZITADEL returns goes only into that mail. Nothing here logs, returns or
|
|
1862
|
+
* throws the personal address or the code -- only the invited user id, handed
|
|
1863
|
+
* back as `resourceId` so `runSteps` records it under
|
|
1864
|
+
* `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
|
|
1865
|
+
* `plan()` call idempotent. A failure after the account exists records
|
|
1866
|
+
* nothing: the next apply finds the account by username and mints a new code.
|
|
1774
1867
|
*/
|
|
1775
1868
|
private zitadelOwner;
|
|
1776
1869
|
/**
|
|
@@ -1992,6 +2085,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1992
2085
|
private bouleBrokerEntry;
|
|
1993
2086
|
/** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
|
|
1994
2087
|
private writeAppBrokerEntry;
|
|
2088
|
+
/**
|
|
2089
|
+
* `null` once the app already has `AUTHZ_PEER_PASSWORD_OPERATOR`: minted only
|
|
2090
|
+
* the first time and never rotated, like {@link ensureAuthCookieSecret}.
|
|
2091
|
+
*/
|
|
2092
|
+
private ensurePeerPassword;
|
|
2093
|
+
/** Same rule for the secret Archon's invite form sends to Boule. */
|
|
2094
|
+
private ensureBridgeSecret;
|
|
2095
|
+
private mintIfAbsent;
|
|
1995
2096
|
/**
|
|
1996
2097
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
1997
2098
|
* only the first time (task brief), never regenerated or overwritten, so a
|
|
@@ -2131,6 +2232,22 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2131
2232
|
* (which must never delete the platform's own project) change.
|
|
2132
2233
|
*/
|
|
2133
2234
|
private ensureOrgLettermintRelay;
|
|
2235
|
+
/**
|
|
2236
|
+
* The company's own sign-in mail token: a Lettermint project `<slug>-auth`
|
|
2237
|
+
* whose token goes to `orgTarget(org).refs.brokerMailKey`, where the broker
|
|
2238
|
+
* and ZITADEL's SMTP settings read it. The platform-wide Lettermint key
|
|
2239
|
+
* stays with the executor and never reaches a company. A held ref is left
|
|
2240
|
+
* alone. Otherwise the mint rule of {@link ensureOrgAppMailToken}: create
|
|
2241
|
+
* the project, restrict the domain, deliver its token, and only then
|
|
2242
|
+
* delete every other `<slug>-auth` project (a token cannot be read back,
|
|
2243
|
+
* so a project without a held token is a leftover). The restriction comes
|
|
2244
|
+
* before the delivery so a failure there leaves no held ref: the next run
|
|
2245
|
+
* starts over instead of skipping it. A failure before the delivery leaves
|
|
2246
|
+
* that project for the next run to delete. Every Lettermint call is
|
|
2247
|
+
* bounded; the token is registered for redaction before anything can throw
|
|
2248
|
+
* with it.
|
|
2249
|
+
*/
|
|
2250
|
+
private ensureOwnAuthMailToken;
|
|
2134
2251
|
/**
|
|
2135
2252
|
* Deletes a just-minted project from inside a `catch`. A failed delete is
|
|
2136
2253
|
* dropped so it never replaces the error being rethrown; the orphan is
|
|
@@ -2626,6 +2743,54 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2626
2743
|
*/
|
|
2627
2744
|
private zonePort;
|
|
2628
2745
|
private jobsPort;
|
|
2746
|
+
/**
|
|
2747
|
+
* `plan/own-diff.ts`'s `planOwnJobs`: the org's own scheduler, in the org's
|
|
2748
|
+
* own project, with the names and the held-key ref of `orgTarget(org)`. The
|
|
2749
|
+
* admin token goes to `jobs:org:<slug>:adminToken` and nowhere else.
|
|
2750
|
+
*/
|
|
2751
|
+
private ownJobs;
|
|
2752
|
+
/**
|
|
2753
|
+
* `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
|
|
2754
|
+
* org's own project, trusting the company's issuer `https://auth.<domain>`.
|
|
2755
|
+
* Its ZITADEL project `<slug>-files` and introspection app are made in the
|
|
2756
|
+
* company's instance (`ownServices.zitadelFor`), never the shared one. The
|
|
2757
|
+
* bucket comes from the host's bucket port under the target's name. The
|
|
2758
|
+
* management key goes to `files:org:<slug>:managementKey` and the
|
|
2759
|
+
* introspection secret to `zitadel:org:<slug>:files:introspection`, both
|
|
2760
|
+
* once, and neither is recorded or returned.
|
|
2761
|
+
*/
|
|
2762
|
+
private ownFiles;
|
|
2763
|
+
/**
|
|
2764
|
+
* `plan/own-diff.ts`'s `planOwnFilesR2`: the storage key of the org's own files bucket. A token
|
|
2765
|
+
* recorded as undelivered is deleted first (its secret cannot be read back, so it can never be
|
|
2766
|
+
* finished), then one is minted, registered as a secret, recorded by id as undelivered and
|
|
2767
|
+
* persisted, and only then sent to the files host, the same pair on every retry. The secret is
|
|
2768
|
+
* never recorded and never written to a Coolify env. The id is recorded once the host has it.
|
|
2769
|
+
*/
|
|
2770
|
+
private ownFilesR2;
|
|
2771
|
+
/**
|
|
2772
|
+
* `plan/own-diff.ts`'s `planOwnAuth`: the org's own sign-in at
|
|
2773
|
+
* `auth.<domain>`, in the org's own project, through the org's own edge
|
|
2774
|
+
* routed DNS and tunnel ports. The instance owner token goes to
|
|
2775
|
+
* `zitadel:org:<slug>:instanceBearer` and is never recorded. A changed
|
|
2776
|
+
* declared auth ref patches both git apps' branch and redeploys them; the
|
|
2777
|
+
* applied marker comes last, so a run that died halfway is planned again.
|
|
2778
|
+
*/
|
|
2779
|
+
private ownAuth;
|
|
2780
|
+
/**
|
|
2781
|
+
* `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
|
|
2782
|
+
* project, with no host port (the shared relay reaches it by internal name)
|
|
2783
|
+
* and a directory that trusts the org's own sign-in. The admin key goes to
|
|
2784
|
+
* `orgTarget(org).refs.mailAdminKey` and nowhere else. The address the relay
|
|
2785
|
+
* routes to is recorded last, and the step's marker after it.
|
|
2786
|
+
*/
|
|
2787
|
+
private ownMail;
|
|
2788
|
+
/**
|
|
2789
|
+
* `plan/own-diff.ts`'s `planOwnSfu`: the org's own LiveKit server in the
|
|
2790
|
+
* org's project, on the ports of its slot. The company's key pair goes to
|
|
2791
|
+
* `livekit:org:<slug>:keys` and the app's env, nowhere else.
|
|
2792
|
+
*/
|
|
2793
|
+
private ownSfu;
|
|
2629
2794
|
private jobsRegistration;
|
|
2630
2795
|
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
2631
2796
|
private deliverWithRetry;
|
|
@@ -2879,6 +3044,11 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2879
3044
|
* #491: when `mailDomain` is the platform's own apex, this must never
|
|
2880
3045
|
* delete the platform's own project (the delete loop skips it by id) or
|
|
2881
3046
|
* the platform's own domain (returns before the domain lookup/delete).
|
|
3047
|
+
*
|
|
3048
|
+
* An org whose own sign-in is applied keeps its `<slug>-auth` project, and
|
|
3049
|
+
* keeps the Lettermint domain when that is the domain the sign-in sends
|
|
3050
|
+
* from. Every other org deletes `<slug>-auth` too, which only a failed
|
|
3051
|
+
* first sign-in run can have left behind.
|
|
2882
3052
|
*/
|
|
2883
3053
|
private parkLettermintMail;
|
|
2884
3054
|
/**
|